claude-notification-plugin 1.1.76 → 1.1.81

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.76",
3
+ "version": "1.1.81",
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,11 @@ 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
+ | `/seen clear` | Wipe the seen list |
257
+ | `/setdefault` | Change the default project |
253
258
  | `/worktrees &project` | List worktrees |
254
259
  | `/worktree &project/branch` | Create a worktree |
255
260
  | `/rmworktree &project/branch` | Remove a worktree |
@@ -260,6 +265,35 @@ Projects are referenced with the `&` prefix (e.g. `&api`, `&api/branch`).
260
265
  | `/menu` | Show help with inline buttons |
261
266
  | `/help` | Show help with inline buttons |
262
267
 
268
+ #### Registering projects on the fly (`/addproject` + `/seen`)
269
+
270
+ Whenever the notifier fires for a folder, it records the absolute path,
271
+ basename and timestamp in `~/.claude/claude-notify.seen.json` (last 30
272
+ entries, oldest auto-evicted). `/seen` shows that list as a table — folders
273
+ already registered as listener projects have their alias in the `alias`
274
+ column; the rest show `—`.
275
+
276
+ `/addproject` accepts two forms for the path argument:
277
+
278
+ ```
279
+ /addproject mj D:/DEV/FA/_cur/mcp-jira — explicit absolute path
280
+ /addproject mj /mcp-jira — basename from /seen (most recent)
281
+ ```
282
+
283
+ The basename form (`/name`) looks up the most recent entry in the seen file
284
+ whose basename equals `name`. This is handy right after receiving a
285
+ notification like `✅ /mcp-jira/master` — no need to retype the full path.
286
+
287
+ Safeguards: alias must be unique, path must exist and be a directory, and
288
+ the same path cannot be registered twice under different aliases.
289
+
290
+ > **Unix note.** A single-segment path like `/mcp-jira` is always
291
+ > interpreted as a basename reference. If you have a real `/mcp-jira`
292
+ > directory at the filesystem root, add a trailing slash to force the
293
+ > explicit-path interpretation: `/addproject mj /mcp-jira/`.
294
+
295
+ `/add-project` and `/add_project` are accepted as aliases for `/addproject`.
296
+
263
297
  ### Listener configuration
264
298
 
265
299
  | 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
- 8d2fc918805c6a42eb0158fbc60e35c34761ed82
1
+ 5028775e89c41e96abd523526de5ff59ab1bb1e7
@@ -674,6 +674,57 @@ 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
+
720
+ `/seen clear` (alias: `/seen reset`) wipes the file — useful when the
721
+ list is full of stale entries after reorganizing project folders.
722
+
723
+ ```
724
+ You: /seen clear
725
+ Bot: ✅ Seen file cleared (17 entries removed).
726
+ ```
727
+
677
728
  ### /worktrees — project worktrees
678
729
 
679
730
  ```
@@ -11,7 +11,17 @@ 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
+ SEEN_PROJECTS_PATH,
20
+ getDefaultProject,
21
+ saveConfig,
22
+ normalizeForCompare,
23
+ loadSeenProjects,
24
+ } from '../bin/constants.js';
15
25
  import { JsonlReader, resolveJsonlPath, resolveJsonlByMtime } from './jsonl-reader.js';
16
26
 
17
27
  // ----------------------
@@ -514,6 +524,12 @@ async function handleCommand (cmd, args) {
514
524
  return handleNewSession(args);
515
525
  case '/projects':
516
526
  return handleProjects();
527
+ case '/add-project':
528
+ case '/add_project':
529
+ case '/addproject':
530
+ return handleAddProject(args);
531
+ case '/seen':
532
+ return handleSeen(args);
517
533
  case '/setdefault':
518
534
  return handleSetDefault(args);
519
535
  case '/worktrees':
@@ -825,6 +841,200 @@ function handleSetDefault (args) {
825
841
  return `✅ Default project: <b>&${escapeHtml(alias)}</b> → <code>${escapeHtml(projPath)}</code>`;
826
842
  }
827
843
 
844
+ // ----------------------
845
+ // /add-project and /seen
846
+ // ----------------------
847
+
848
+ function isBasenameRef (s) {
849
+ // "/foo" — one leading slash + single path segment, no second slash,
850
+ // no backslash, no drive letter. Everything else → explicit path.
851
+ return /^\/[^/\\:]+$/.test(s);
852
+ }
853
+
854
+ function formatAge (iso) {
855
+ if (!iso) {
856
+ return '?';
857
+ }
858
+ const diffMs = Date.now() - new Date(iso).getTime();
859
+ if (Number.isNaN(diffMs)) {
860
+ return '?';
861
+ }
862
+ if (diffMs < 0) {
863
+ return 'now';
864
+ }
865
+ const s = Math.floor(diffMs / 1000);
866
+ if (s < 60) {
867
+ return `${s}s ago`;
868
+ }
869
+ const m = Math.floor(s / 60);
870
+ if (m < 60) {
871
+ return `${m}m ago`;
872
+ }
873
+ const h = Math.floor(m / 60);
874
+ if (h < 24) {
875
+ return `${h}h ago`;
876
+ }
877
+ const d = Math.floor(h / 24);
878
+ if (d < 30) {
879
+ return `${d}d ago`;
880
+ }
881
+ return new Date(iso).toISOString().slice(0, 10);
882
+ }
883
+
884
+ function handleAddProject (args) {
885
+ const trimmed = (args || '').trim();
886
+ const usage = `❌ Usage: /addproject &lt;alias&gt; &lt;path-or-/basename&gt;
887
+
888
+ Examples:
889
+ /addproject mj D:/DEV/FA/_cur/mcp-jira — explicit path
890
+ /addproject mj /mcp-jira — resolve from last notification
891
+ /addproject mj /mcp-jira/ — Unix: literal /mcp-jira directory
892
+
893
+ Aliases for this command: /add-project, /add_project`;
894
+
895
+ if (!trimmed) {
896
+ return usage;
897
+ }
898
+ const parts = trimmed.split(/\s+/);
899
+ if (parts.length < 2) {
900
+ return usage;
901
+ }
902
+ const alias = parts[0];
903
+ const rawTarget = parts.slice(1).join(' ');
904
+
905
+ if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
906
+ return `❌ Invalid alias "<b>${escapeHtml(alias)}</b>". Allowed: letters, digits, underscore, hyphen.`;
907
+ }
908
+ if (listenerConfig.projects[alias]) {
909
+ return `❌ Alias "<b>${escapeHtml(alias)}</b>" already exists. Use /projects to list.`;
910
+ }
911
+
912
+ // Resolve target → absolute path
913
+ let absPath;
914
+ if (isBasenameRef(rawTarget)) {
915
+ const { entries } = loadSeenProjects();
916
+ const basename = rawTarget.replace(/^\/+/, '');
917
+ const matches = entries
918
+ .filter((e) => e.basename === basename)
919
+ .sort((a, b) => (b.lastSeen || '').localeCompare(a.lastSeen || ''));
920
+ if (matches.length === 0) {
921
+ return `❌ Unknown basename "/${escapeHtml(basename)}". No notification from such folder was seen yet. Use /seen to list recent folders.`;
922
+ }
923
+ absPath = matches[0].path;
924
+ } else {
925
+ absPath = rawTarget;
926
+ }
927
+
928
+ // Validate directory exists
929
+ try {
930
+ if (!fs.statSync(absPath).isDirectory()) {
931
+ return `❌ Path is not a directory: <code>${escapeHtml(absPath)}</code>`;
932
+ }
933
+ } catch {
934
+ return `❌ Path does not exist: <code>${escapeHtml(absPath)}</code>`;
935
+ }
936
+
937
+ // Normalize to forward slashes (match existing config style)
938
+ absPath = absPath.replace(/\\/g, '/');
939
+
940
+ // Check: path already registered under another alias?
941
+ const normalizedNew = normalizeForCompare(absPath);
942
+ for (const [existingAlias, proj] of Object.entries(listenerConfig.projects)) {
943
+ const existingPath = typeof proj === 'string' ? proj : proj?.path;
944
+ if (!existingPath) {
945
+ continue;
946
+ }
947
+ if (normalizeForCompare(existingPath) === normalizedNew) {
948
+ return `❌ Path already registered as <b>&${escapeHtml(existingAlias)}</b> → <code>${escapeHtml(existingPath)}</code>`;
949
+ }
950
+ }
951
+
952
+ // Mutate config + persist
953
+ listenerConfig.projects[alias] = {
954
+ path: absPath,
955
+ claudeArgs: [],
956
+ worktrees: {},
957
+ };
958
+ try {
959
+ saveConfig(config);
960
+ } catch (err) {
961
+ delete listenerConfig.projects[alias];
962
+ logger.error(`Failed to save config: ${err.message}`);
963
+ return `❌ Failed to save config: ${escapeHtml(err.message)}`;
964
+ }
965
+
966
+ // Discover worktrees for the new project (consistency with startup flow)
967
+ try {
968
+ worktreeManager.discoverWorktrees(alias);
969
+ } catch (err) {
970
+ logger.warn(`discoverWorktrees failed for ${alias}: ${err.message}`);
971
+ }
972
+
973
+ logger.info(`Project added: ${alias} → ${absPath}`);
974
+ return `✅ Project added: <b>&${escapeHtml(alias)}</b> → <code>${escapeHtml(absPath)}</code>`;
975
+ }
976
+
977
+ function handleSeen (args) {
978
+ const sub = (args || '').trim().toLowerCase();
979
+
980
+ if (sub === 'clear' || sub === 'reset') {
981
+ let count = 0;
982
+ try {
983
+ const data = loadSeenProjects();
984
+ count = data.entries.length;
985
+ // Atomic overwrite with an empty list
986
+ const tmp = `${SEEN_PROJECTS_PATH}.${process.pid}.tmp`;
987
+ fs.writeFileSync(tmp, JSON.stringify({ entries: [] }, null, 2));
988
+ fs.renameSync(tmp, SEEN_PROJECTS_PATH);
989
+ } catch (err) {
990
+ logger.error(`Failed to clear seen file: ${err.message}`);
991
+ return `❌ Failed to clear seen file: ${escapeHtml(err.message)}`;
992
+ }
993
+ logger.info(`Seen file cleared (${count} entries removed)`);
994
+ return `✅ Seen file cleared (${count} entries removed).`;
995
+ }
996
+
997
+ if (sub && sub !== '') {
998
+ return `❌ Unknown subcommand "<b>${escapeHtml(sub)}</b>". Usage: /seen [clear]`;
999
+ }
1000
+
1001
+ const { entries } = loadSeenProjects();
1002
+ if (!entries || entries.length === 0) {
1003
+ return 'ℹ No seen folders yet. Notifier will populate this list as you receive notifications.';
1004
+ }
1005
+
1006
+ // Build alias index: normalized project path → alias
1007
+ const aliasByPath = new Map();
1008
+ for (const [alias, proj] of Object.entries(listenerConfig.projects)) {
1009
+ const p = typeof proj === 'string' ? proj : proj?.path;
1010
+ if (p) {
1011
+ aliasByPath.set(normalizeForCompare(p), alias);
1012
+ }
1013
+ }
1014
+
1015
+ // Sort by lastSeen desc (defensive — notifier already does this)
1016
+ const sorted = [...entries].sort(
1017
+ (a, b) => (b.lastSeen || '').localeCompare(a.lastSeen || ''),
1018
+ );
1019
+
1020
+ const rows = sorted.map((e, i) => ({
1021
+ num: String(i + 1),
1022
+ alias: aliasByPath.get(normalizeForCompare(e.path)) || '—',
1023
+ age: formatAge(e.lastSeen),
1024
+ projPath: e.path,
1025
+ }));
1026
+ const wNum = Math.max(...rows.map((r) => r.num.length));
1027
+ const wAlias = Math.max(...rows.map((r) => r.alias.length), 5);
1028
+ const wAge = Math.max(...rows.map((r) => r.age.length), 3);
1029
+
1030
+ const lines = rows.map((r) => `${r.num.padStart(wNum)} ${r.alias.padEnd(wAlias)} ${r.age.padStart(wAge)} ${r.projPath}`);
1031
+
1032
+ return {
1033
+ text: `📂 <b>Recent folders</b> (${rows.length}/${MAX_SEEN_ENTRIES}):
1034
+ <pre>${escapeHtml(lines.join('\n'))}</pre>`,
1035
+ };
1036
+ }
1037
+
828
1038
  function handleWorktrees (args) {
829
1039
  const target = parseTarget(args);
830
1040
  if (!target) {
@@ -1011,6 +1221,9 @@ function handleHelp () {
1011
1221
  /clear &project[/branch] — clear queue + reset session
1012
1222
  /newsession [&project[/branch]] — reset session (keep queue)
1013
1223
  /projects — list projects
1224
+ /addproject &lt;alias&gt; &lt;path-or-/basename&gt; — register a project
1225
+ /seen — recent folders seen by notifier
1226
+ /seen clear — wipe the seen list
1014
1227
  /setdefault — change default project
1015
1228
  /worktrees &project — project worktrees
1016
1229
  /worktree &project/branch — create worktree
@@ -1163,6 +1376,8 @@ async function mainLoop () {
1163
1376
  { command: 'status', description: 'Status of all projects' },
1164
1377
  { command: 'queue', description: 'Show all queues' },
1165
1378
  { command: 'projects', description: 'List projects' },
1379
+ { command: 'addproject', description: 'Register a project alias' },
1380
+ { command: 'seen', description: 'Recent folders seen by notifier' },
1166
1381
  { command: 'setdefault', description: 'Change default project' },
1167
1382
  { command: 'history', description: 'Recent task history' },
1168
1383
  { 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,22 +32,18 @@ function debugLog (config, ...args) {
26
32
  }
27
33
  }
28
34
 
29
- function normalizePath (p) {
30
- return path.resolve(p).replace(/\\/g, '/').toLowerCase();
31
- }
32
-
33
35
  function resolveProjectName (cwd, config) {
34
36
  const fallback = path.basename(cwd);
35
37
  const projects = config?.listenerProjects;
36
38
  if (!projects || typeof projects !== 'object') {
37
39
  return fallback;
38
40
  }
39
- const normalizedCwd = normalizePath(cwd);
41
+ const normalizedCwd = normalizeForCompare(cwd);
40
42
  for (const entry of Object.values(projects)) {
41
43
  if (!entry?.path) {
42
44
  continue;
43
45
  }
44
- if (normalizedCwd === normalizePath(entry.path) && entry.name) {
46
+ if (normalizedCwd === normalizeForCompare(entry.path) && entry.name) {
45
47
  return entry.name;
46
48
  }
47
49
  }
@@ -748,6 +750,10 @@ process.stdin.on('end', async () => {
748
750
  const project = resolveProjectName(cwd, config);
749
751
  const sessionId = event.session_id || 'default';
750
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
+
751
757
  const disabled = isNotifierDisabled();
752
758
  if (disabled === true) {
753
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.76",
4
+ "version": "1.1.81",
5
5
  "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
6
  "type": "module",
7
7
  "engines": {