claude-notification-plugin 1.1.75 → 1.1.78

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-notification-plugin",
3
- "version": "1.1.75",
3
+ "version": "1.1.78",
4
4
  "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
5
5
  "author": {
6
6
  "name": "Viacheslav Makarov",
package/README.md CHANGED
@@ -250,6 +250,10 @@ Projects are referenced with the `&` prefix (e.g. `&api`, `&api/branch`).
250
250
  | `/clear &project[/branch]` | Clear queue + reset session |
251
251
  | `/newsession [&project[/branch]]` | Reset session only (keep queue) |
252
252
  | `/projects` | List projects and paths |
253
+ | `/addproject <alias> <path>` | Register a project alias |
254
+ | `/addproject <alias> /<basename>` | Register using basename from `/seen` |
255
+ | `/seen` | Recent folders seen by notifier |
256
+ | `/setdefault` | Change the default project |
253
257
  | `/worktrees &project` | List worktrees |
254
258
  | `/worktree &project/branch` | Create a worktree |
255
259
  | `/rmworktree &project/branch` | Remove a worktree |
@@ -260,6 +264,35 @@ Projects are referenced with the `&` prefix (e.g. `&api`, `&api/branch`).
260
264
  | `/menu` | Show help with inline buttons |
261
265
  | `/help` | Show help with inline buttons |
262
266
 
267
+ #### Registering projects on the fly (`/addproject` + `/seen`)
268
+
269
+ Whenever the notifier fires for a folder, it records the absolute path,
270
+ basename and timestamp in `~/.claude/claude-notify.seen.json` (last 30
271
+ entries, oldest auto-evicted). `/seen` shows that list as a table — folders
272
+ already registered as listener projects have their alias in the `alias`
273
+ column; the rest show `—`.
274
+
275
+ `/addproject` accepts two forms for the path argument:
276
+
277
+ ```
278
+ /addproject mj D:/DEV/FA/_cur/mcp-jira — explicit absolute path
279
+ /addproject mj /mcp-jira — basename from /seen (most recent)
280
+ ```
281
+
282
+ The basename form (`/name`) looks up the most recent entry in the seen file
283
+ whose basename equals `name`. This is handy right after receiving a
284
+ notification like `✅ /mcp-jira/master` — no need to retype the full path.
285
+
286
+ Safeguards: alias must be unique, path must exist and be a directory, and
287
+ the same path cannot be registered twice under different aliases.
288
+
289
+ > **Unix note.** A single-segment path like `/mcp-jira` is always
290
+ > interpreted as a basename reference. If you have a real `/mcp-jira`
291
+ > directory at the filesystem root, add a trailing slash to force the
292
+ > explicit-path interpretation: `/addproject mj /mcp-jira/`.
293
+
294
+ `/add-project` and `/add_project` are accepted as aliases for `/addproject`.
295
+
263
296
  ### Listener configuration
264
297
 
265
298
  | Parameter | Default | Description |
package/bin/constants.js CHANGED
@@ -8,6 +8,7 @@ export const PLUGINS_DIR = path.join(CLAUDE_DIR, 'plugins');
8
8
 
9
9
  // File names
10
10
  export const CONFIG_FILENAME = 'claude-notify.config.json';
11
+ export const SEEN_PROJECTS_FILENAME = 'claude-notify.seen.json';
11
12
  export const STATE_FILENAME = '.notifier_state.json';
12
13
  export const PID_FILENAME = '.listener.pid';
13
14
  export const RESOLVER_FILENAME = 'claude-notify-resolve.js';
@@ -19,6 +20,8 @@ export const PTY_SIGNAL_DIR = path.join(CLAUDE_DIR, 'pty-signals');
19
20
 
20
21
  // Full paths
21
22
  export const CONFIG_PATH = path.join(CLAUDE_DIR, CONFIG_FILENAME);
23
+ export const SEEN_PROJECTS_PATH = path.join(CLAUDE_DIR, SEEN_PROJECTS_FILENAME);
24
+ export const MAX_SEEN_ENTRIES = 30;
22
25
  export const STATE_PATH = path.join(CLAUDE_DIR, STATE_FILENAME);
23
26
  export const PID_PATH = path.join(CLAUDE_DIR, PID_FILENAME);
24
27
  export const RESOLVER_PATH = path.join(CLAUDE_DIR, RESOLVER_FILENAME);
@@ -65,6 +68,79 @@ export function saveConfig (config) {
65
68
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
66
69
  }
67
70
 
71
+ /**
72
+ * Normalize a filesystem path for equality comparison.
73
+ * - Resolves ".", "..", trailing slashes, mixed separators.
74
+ * - Uses forward slashes.
75
+ * - Lowercases on Windows (case-insensitive FS).
76
+ */
77
+ export function normalizeForCompare (p) {
78
+ if (!p) {
79
+ return '';
80
+ }
81
+ const resolved = path.resolve(p).replace(/\\/g, '/');
82
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
83
+ }
84
+
85
+ /**
86
+ * Load the seen-projects file. Returns { entries: [] } on any error.
87
+ * Schema: { entries: [{ path, basename, lastSeen }] }
88
+ */
89
+ export function loadSeenProjects () {
90
+ try {
91
+ const raw = fs.readFileSync(SEEN_PROJECTS_PATH, 'utf-8');
92
+ const data = JSON.parse(raw);
93
+ if (data && Array.isArray(data.entries)) {
94
+ return { entries: data.entries };
95
+ }
96
+ } catch {
97
+ // fall through
98
+ }
99
+ return { entries: [] };
100
+ }
101
+
102
+ /**
103
+ * Record a folder as "seen" by the notifier. Updates lastSeen in-place
104
+ * if the path already exists, otherwise appends a new entry. Sorts by
105
+ * lastSeen desc and trims to MAX_SEEN_ENTRIES.
106
+ * Silent on errors — never throws.
107
+ */
108
+ export function recordSeenProject (cwd) {
109
+ try {
110
+ if (!cwd) {
111
+ return;
112
+ }
113
+ const normalized = normalizeForCompare(cwd);
114
+ const data = loadSeenProjects();
115
+ const entries = data.entries;
116
+
117
+ const now = new Date().toISOString();
118
+ const idx = entries.findIndex(
119
+ (e) => normalizeForCompare(e.path) === normalized
120
+ );
121
+ if (idx >= 0) {
122
+ entries[idx].lastSeen = now;
123
+ entries[idx].path = cwd.replace(/\\/g, '/');
124
+ entries[idx].basename = path.basename(cwd);
125
+ } else {
126
+ entries.push({
127
+ path: cwd.replace(/\\/g, '/'),
128
+ basename: path.basename(cwd),
129
+ lastSeen: now,
130
+ });
131
+ }
132
+
133
+ entries.sort((a, b) => (b.lastSeen || '').localeCompare(a.lastSeen || ''));
134
+ const trimmed = entries.slice(0, MAX_SEEN_ENTRIES);
135
+
136
+ const tmp = `${SEEN_PROJECTS_PATH}.${process.pid}.tmp`;
137
+ fs.writeFileSync(tmp, JSON.stringify({ entries: trimmed }, null, 2));
138
+ fs.renameSync(tmp, SEEN_PROJECTS_PATH);
139
+ } catch {
140
+ // silent: notifier must not crash on seen-file errors
141
+ }
142
+ }
143
+
68
144
  // Plugin identity
69
145
  export const HOOK_COMMAND = 'claude-notify';
70
146
  export const MARKETPLACE_KEY = 'bazilio-plugins';
package/commit-sha CHANGED
@@ -1 +1 @@
1
- 09e7c656d459f08696ec5408b01cb3ca67045d52
1
+ 865e188dabd02a23bbb2e38a9a89ea21fa0c3e07
@@ -674,6 +674,49 @@ Bot: 📂 Projects:
674
674
  &web → /home/user/projects/web-app
675
675
  ```
676
676
 
677
+ ### /addproject — register a project alias
678
+
679
+ Registers a new entry in `listener.projects` without manual config editing.
680
+ Two forms for the path argument:
681
+
682
+ ```
683
+ You: /addproject mj D:/DEV/FA/_cur/mcp-jira
684
+ Bot: ✅ Project added: &mj → D:/DEV/FA/_cur/mcp-jira
685
+
686
+ You: /addproject mj /mcp-jira
687
+ Bot: ✅ Project added: &mj → D:/DEV/FA/_cur/mcp-jira
688
+ ```
689
+
690
+ The basename form (`/name`) resolves via `~/.claude/claude-notify.seen.json`,
691
+ populated by the notifier on every hook. The listener picks the most recent
692
+ entry whose basename equals `name`.
693
+
694
+ Refuses to add when: alias is already taken, path doesn't exist, path is
695
+ not a directory, or the same path is already registered under another
696
+ alias (compared case-insensitively on Windows).
697
+
698
+ Aliases: `/add-project`, `/add_project` work as well (Telegram's menu only
699
+ allows `[a-z0-9_]`, so the canonical short form is `addproject`).
700
+
701
+ ### /seen — recent folders seen by notifier
702
+
703
+ Shows the last 30 folders for which the notifier fired, as a monospace
704
+ table. Rows already registered as listener projects display their alias;
705
+ others show `—`.
706
+
707
+ ```
708
+ You: /seen
709
+ Bot: 📂 Recent folders (4/30):
710
+ 1 mj 2m ago D:/DEV/FA/_cur/mcp-jira
711
+ 2 — 15m ago C:/work/side-project
712
+ 3 notify 1h ago D:/DEV/FA/_pub/claude-notification-plugin
713
+ 4 — 3d ago C:/tmp/old-repo
714
+ ```
715
+
716
+ The seen file (`~/.claude/claude-notify.seen.json`) is written atomically
717
+ by the notifier on every hook event; oldest entries are evicted when the
718
+ count exceeds 30.
719
+
677
720
  ### /worktrees — project worktrees
678
721
 
679
722
  ```
@@ -11,7 +11,16 @@ import { WorkQueue } from './work-queue.js';
11
11
  import { PtyRunner } from './pty-runner.js';
12
12
  import { WorktreeManager } from './worktree-manager.js';
13
13
  import { parseMessage, parseTarget } from './message-parser.js';
14
- import { CLAUDE_DIR, CONFIG_PATH, LISTENER_LOG_FILENAME, getDefaultProject, saveConfig } from '../bin/constants.js';
14
+ import {
15
+ CLAUDE_DIR,
16
+ CONFIG_PATH,
17
+ LISTENER_LOG_FILENAME,
18
+ MAX_SEEN_ENTRIES,
19
+ getDefaultProject,
20
+ saveConfig,
21
+ normalizeForCompare,
22
+ loadSeenProjects,
23
+ } from '../bin/constants.js';
15
24
  import { JsonlReader, resolveJsonlPath, resolveJsonlByMtime } from './jsonl-reader.js';
16
25
 
17
26
  // ----------------------
@@ -514,6 +523,12 @@ async function handleCommand (cmd, args) {
514
523
  return handleNewSession(args);
515
524
  case '/projects':
516
525
  return handleProjects();
526
+ case '/add-project':
527
+ case '/add_project':
528
+ case '/addproject':
529
+ return handleAddProject(args);
530
+ case '/seen':
531
+ return handleSeen();
517
532
  case '/setdefault':
518
533
  return handleSetDefault(args);
519
534
  case '/worktrees':
@@ -825,6 +840,177 @@ function handleSetDefault (args) {
825
840
  return `✅ Default project: <b>&${escapeHtml(alias)}</b> → <code>${escapeHtml(projPath)}</code>`;
826
841
  }
827
842
 
843
+ // ----------------------
844
+ // /add-project and /seen
845
+ // ----------------------
846
+
847
+ function isBasenameRef (s) {
848
+ // "/foo" — one leading slash + single path segment, no second slash,
849
+ // no backslash, no drive letter. Everything else → explicit path.
850
+ return /^\/[^/\\:]+$/.test(s);
851
+ }
852
+
853
+ function formatAge (iso) {
854
+ if (!iso) {
855
+ return '?';
856
+ }
857
+ const diffMs = Date.now() - new Date(iso).getTime();
858
+ if (Number.isNaN(diffMs)) {
859
+ return '?';
860
+ }
861
+ if (diffMs < 0) {
862
+ return 'now';
863
+ }
864
+ const s = Math.floor(diffMs / 1000);
865
+ if (s < 60) {
866
+ return `${s}s ago`;
867
+ }
868
+ const m = Math.floor(s / 60);
869
+ if (m < 60) {
870
+ return `${m}m ago`;
871
+ }
872
+ const h = Math.floor(m / 60);
873
+ if (h < 24) {
874
+ return `${h}h ago`;
875
+ }
876
+ const d = Math.floor(h / 24);
877
+ if (d < 30) {
878
+ return `${d}d ago`;
879
+ }
880
+ return new Date(iso).toISOString().slice(0, 10);
881
+ }
882
+
883
+ function handleAddProject (args) {
884
+ const trimmed = (args || '').trim();
885
+ const usage = `❌ Usage: /addproject &lt;alias&gt; &lt;path-or-/basename&gt;
886
+
887
+ Examples:
888
+ /addproject mj D:/DEV/FA/_cur/mcp-jira — explicit path
889
+ /addproject mj /mcp-jira — resolve from last notification
890
+ /addproject mj /mcp-jira/ — Unix: literal /mcp-jira directory
891
+
892
+ Aliases for this command: /add-project, /add_project`;
893
+
894
+ if (!trimmed) {
895
+ return usage;
896
+ }
897
+ const parts = trimmed.split(/\s+/);
898
+ if (parts.length < 2) {
899
+ return usage;
900
+ }
901
+ const alias = parts[0];
902
+ const rawTarget = parts.slice(1).join(' ');
903
+
904
+ if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
905
+ return `❌ Invalid alias "<b>${escapeHtml(alias)}</b>". Allowed: letters, digits, underscore, hyphen.`;
906
+ }
907
+ if (listenerConfig.projects[alias]) {
908
+ return `❌ Alias "<b>${escapeHtml(alias)}</b>" already exists. Use /projects to list.`;
909
+ }
910
+
911
+ // Resolve target → absolute path
912
+ let absPath;
913
+ if (isBasenameRef(rawTarget)) {
914
+ const { entries } = loadSeenProjects();
915
+ const basename = rawTarget.replace(/^\/+/, '');
916
+ const matches = entries
917
+ .filter((e) => e.basename === basename)
918
+ .sort((a, b) => (b.lastSeen || '').localeCompare(a.lastSeen || ''));
919
+ if (matches.length === 0) {
920
+ return `❌ Unknown basename "/${escapeHtml(basename)}". No notification from such folder was seen yet. Use /seen to list recent folders.`;
921
+ }
922
+ absPath = matches[0].path;
923
+ } else {
924
+ absPath = rawTarget;
925
+ }
926
+
927
+ // Validate directory exists
928
+ try {
929
+ if (!fs.statSync(absPath).isDirectory()) {
930
+ return `❌ Path is not a directory: <code>${escapeHtml(absPath)}</code>`;
931
+ }
932
+ } catch {
933
+ return `❌ Path does not exist: <code>${escapeHtml(absPath)}</code>`;
934
+ }
935
+
936
+ // Normalize to forward slashes (match existing config style)
937
+ absPath = absPath.replace(/\\/g, '/');
938
+
939
+ // Check: path already registered under another alias?
940
+ const normalizedNew = normalizeForCompare(absPath);
941
+ for (const [existingAlias, proj] of Object.entries(listenerConfig.projects)) {
942
+ const existingPath = typeof proj === 'string' ? proj : proj?.path;
943
+ if (!existingPath) {
944
+ continue;
945
+ }
946
+ if (normalizeForCompare(existingPath) === normalizedNew) {
947
+ return `❌ Path already registered as <b>&${escapeHtml(existingAlias)}</b> → <code>${escapeHtml(existingPath)}</code>`;
948
+ }
949
+ }
950
+
951
+ // Mutate config + persist
952
+ listenerConfig.projects[alias] = {
953
+ path: absPath,
954
+ claudeArgs: [],
955
+ worktrees: {},
956
+ };
957
+ try {
958
+ saveConfig(config);
959
+ } catch (err) {
960
+ delete listenerConfig.projects[alias];
961
+ logger.error(`Failed to save config: ${err.message}`);
962
+ return `❌ Failed to save config: ${escapeHtml(err.message)}`;
963
+ }
964
+
965
+ // Discover worktrees for the new project (consistency with startup flow)
966
+ try {
967
+ worktreeManager.discoverWorktrees(alias);
968
+ } catch (err) {
969
+ logger.warn(`discoverWorktrees failed for ${alias}: ${err.message}`);
970
+ }
971
+
972
+ logger.info(`Project added: ${alias} → ${absPath}`);
973
+ return `✅ Project added: <b>&${escapeHtml(alias)}</b> → <code>${escapeHtml(absPath)}</code>`;
974
+ }
975
+
976
+ function handleSeen () {
977
+ const { entries } = loadSeenProjects();
978
+ if (!entries || entries.length === 0) {
979
+ return 'ℹ No seen folders yet. Notifier will populate this list as you receive notifications.';
980
+ }
981
+
982
+ // Build alias index: normalized project path → alias
983
+ const aliasByPath = new Map();
984
+ for (const [alias, proj] of Object.entries(listenerConfig.projects)) {
985
+ const p = typeof proj === 'string' ? proj : proj?.path;
986
+ if (p) {
987
+ aliasByPath.set(normalizeForCompare(p), alias);
988
+ }
989
+ }
990
+
991
+ // Sort by lastSeen desc (defensive — notifier already does this)
992
+ const sorted = [...entries].sort(
993
+ (a, b) => (b.lastSeen || '').localeCompare(a.lastSeen || ''),
994
+ );
995
+
996
+ const rows = sorted.map((e, i) => ({
997
+ num: String(i + 1),
998
+ alias: aliasByPath.get(normalizeForCompare(e.path)) || '—',
999
+ age: formatAge(e.lastSeen),
1000
+ projPath: e.path,
1001
+ }));
1002
+ const wNum = Math.max(...rows.map((r) => r.num.length));
1003
+ const wAlias = Math.max(...rows.map((r) => r.alias.length), 5);
1004
+ const wAge = Math.max(...rows.map((r) => r.age.length), 3);
1005
+
1006
+ const lines = rows.map((r) => `${r.num.padStart(wNum)} ${r.alias.padEnd(wAlias)} ${r.age.padStart(wAge)} ${r.projPath}`);
1007
+
1008
+ return {
1009
+ text: `📂 <b>Recent folders</b> (${rows.length}/${MAX_SEEN_ENTRIES}):
1010
+ <pre>${escapeHtml(lines.join('\n'))}</pre>`,
1011
+ };
1012
+ }
1013
+
828
1014
  function handleWorktrees (args) {
829
1015
  const target = parseTarget(args);
830
1016
  if (!target) {
@@ -1011,6 +1197,8 @@ function handleHelp () {
1011
1197
  /clear &project[/branch] — clear queue + reset session
1012
1198
  /newsession [&project[/branch]] — reset session (keep queue)
1013
1199
  /projects — list projects
1200
+ /addproject &lt;alias&gt; &lt;path-or-/basename&gt; — register a project
1201
+ /seen — recent folders seen by notifier
1014
1202
  /setdefault — change default project
1015
1203
  /worktrees &project — project worktrees
1016
1204
  /worktree &project/branch — create worktree
@@ -1163,6 +1351,8 @@ async function mainLoop () {
1163
1351
  { command: 'status', description: 'Status of all projects' },
1164
1352
  { command: 'queue', description: 'Show all queues' },
1165
1353
  { command: 'projects', description: 'List projects' },
1354
+ { command: 'addproject', description: 'Register a project alias' },
1355
+ { command: 'seen', description: 'Recent folders seen by notifier' },
1166
1356
  { command: 'setdefault', description: 'Change default project' },
1167
1357
  { command: 'history', description: 'Recent task history' },
1168
1358
  { command: 'pty', description: 'PTY session diagnostics' },
@@ -4,7 +4,13 @@ import fs from 'fs';
4
4
  import path from 'path';
5
5
  import process from 'process';
6
6
  import { execSync, spawn } from 'child_process';
7
- import { CONFIG_PATH, STATE_PATH, PTY_SIGNAL_DIR } from '../bin/constants.js';
7
+ import {
8
+ CONFIG_PATH,
9
+ STATE_PATH,
10
+ PTY_SIGNAL_DIR,
11
+ normalizeForCompare,
12
+ recordSeenProject,
13
+ } from '../bin/constants.js';
8
14
 
9
15
  // ----------------------
10
16
  // CONFIG
@@ -26,6 +32,24 @@ function debugLog (config, ...args) {
26
32
  }
27
33
  }
28
34
 
35
+ function resolveProjectName (cwd, config) {
36
+ const fallback = path.basename(cwd);
37
+ const projects = config?.listenerProjects;
38
+ if (!projects || typeof projects !== 'object') {
39
+ return fallback;
40
+ }
41
+ const normalizedCwd = normalizeForCompare(cwd);
42
+ for (const entry of Object.values(projects)) {
43
+ if (!entry?.path) {
44
+ continue;
45
+ }
46
+ if (normalizedCwd === normalizeForCompare(entry.path) && entry.name) {
47
+ return entry.name;
48
+ }
49
+ }
50
+ return fallback;
51
+ }
52
+
29
53
  function getBranch (cwd) {
30
54
  try {
31
55
  return execSync('git rev-parse --abbrev-ref HEAD', {
@@ -98,6 +122,9 @@ function loadConfig () {
98
122
  if (typeof user.webhookUrl === 'string') {
99
123
  config.webhookUrl = user.webhookUrl;
100
124
  }
125
+ if (user.listener?.projects) {
126
+ config.listenerProjects = user.listener.projects;
127
+ }
101
128
  } catch {
102
129
  // ignore malformed config
103
130
  }
@@ -720,9 +747,13 @@ process.stdin.on('end', async () => {
720
747
 
721
748
  const eventType = event.hook_event_name || 'unknown';
722
749
  const cwd = event.cwd || process.cwd();
723
- const project = path.basename(cwd);
750
+ const project = resolveProjectName(cwd, config);
724
751
  const sessionId = event.session_id || 'default';
725
752
 
753
+ // Record this cwd in the seen-projects file for /add-project /basename
754
+ // resolution and the /seen listener command. Silent on errors.
755
+ recordSeenProject(cwd);
756
+
726
757
  const disabled = isNotifierDisabled();
727
758
  if (disabled === true) {
728
759
  process.exit(0);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-notification-plugin",
3
3
  "productName": "claude-notification-plugin",
4
- "version": "1.1.75",
4
+ "version": "1.1.78",
5
5
  "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
6
  "type": "module",
7
7
  "engines": {