claude-notification-plugin 1.1.49 → 1.1.55

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.
@@ -68,12 +68,9 @@ export class PtyRunner extends EventEmitter {
68
68
 
69
69
  /**
70
70
  * Check for new marker files in the signal directory.
71
+ * Handles typed signals: stop (default), error, ready, activity, compact.
71
72
  */
72
73
  _checkMarkerFiles () {
73
- if (this.pendingMarkers.size === 0) {
74
- return;
75
- }
76
-
77
74
  let files;
78
75
  try {
79
76
  files = fs.readdirSync(PTY_SIGNAL_DIR);
@@ -94,26 +91,96 @@ export class PtyRunner extends EventEmitter {
94
91
  continue;
95
92
  }
96
93
 
97
- // Try to match by cwd (primary matching for PTY runner)
98
94
  const cwd = marker.cwd;
99
- if (cwd) {
95
+ if (!cwd) {
96
+ continue;
97
+ }
98
+
99
+ const type = marker.type || 'stop';
100
+
101
+ if (type === 'stop') {
102
+ // Completion signal — resolve pending marker
103
+ if (this.pendingMarkers.size === 0) {
104
+ continue;
105
+ }
100
106
  for (const [pid, resolve] of this.pendingMarkers) {
101
107
  const session = this._findSessionByPendingId(pid);
102
108
  if (session && this._normalizePath(session.workDir) === this._normalizePath(cwd)) {
103
109
  this.pendingMarkers.delete(pid);
104
- try {
105
- fs.unlinkSync(filePath);
106
- } catch {
107
- // ignore
108
- }
110
+ this._unlinkSafe(filePath);
109
111
  resolve(marker);
110
112
  break;
111
113
  }
112
114
  }
115
+ } else if (type === 'error') {
116
+ // StopFailure — emit error, abort task
117
+ this._unlinkSafe(filePath);
118
+ for (const [workDir, session] of this.sessions) {
119
+ if (session.state === 'busy' && this._normalizePath(session.workDir) === this._normalizePath(cwd)) {
120
+ if (session._pendingId && this.pendingMarkers.has(session._pendingId)) {
121
+ this.pendingMarkers.delete(session._pendingId);
122
+ }
123
+ const task = session.currentTask;
124
+ session.state = 'idle';
125
+ session.currentTask = null;
126
+ this._destroyPty(workDir);
127
+ const errorMsg = `API error: ${marker.error}${marker.errorDetails ? ' — ' + marker.errorDetails : ''}`;
128
+ this.logger.error(`Hook signal: ${errorMsg} in ${workDir}`);
129
+ if (this.taskLogger) {
130
+ this.taskLogger.logAnswer(task?.project || 'unknown', task?.branch || 'main', errorMsg, 1);
131
+ }
132
+ this.emit('error', workDir, task, errorMsg);
133
+ break;
134
+ }
135
+ }
136
+ } else if (type === 'ready') {
137
+ // SessionStart — emit ready event
138
+ this._unlinkSafe(filePath);
139
+ for (const [workDir, session] of this.sessions) {
140
+ if (this._normalizePath(session.workDir) === this._normalizePath(cwd)) {
141
+ session._model = marker.model || '';
142
+ this.emit('ready', workDir, marker);
143
+ break;
144
+ }
145
+ }
146
+ } else if (type === 'activity') {
147
+ // PostToolUse — update activity data (don't delete, gets overwritten)
148
+ for (const [, session] of this.sessions) {
149
+ if (this._normalizePath(session.workDir) === this._normalizePath(cwd)) {
150
+ session._lastActivity = {
151
+ toolName: marker.toolName,
152
+ toolInput: marker.toolInput,
153
+ timestamp: marker.timestamp,
154
+ };
155
+ break;
156
+ }
157
+ }
158
+ } else if (type === 'compact') {
159
+ // PostCompact — update compaction info
160
+ this._unlinkSafe(filePath);
161
+ for (const [workDir, session] of this.sessions) {
162
+ if (this._normalizePath(session.workDir) === this._normalizePath(cwd)) {
163
+ session._lastCompact = {
164
+ summary: marker.summary,
165
+ trigger: marker.trigger,
166
+ timestamp: marker.timestamp,
167
+ };
168
+ this.emit('compact', workDir, marker);
169
+ break;
170
+ }
171
+ }
113
172
  }
114
173
  }
115
174
  }
116
175
 
176
+ _unlinkSafe (filePath) {
177
+ try {
178
+ fs.unlinkSync(filePath);
179
+ } catch {
180
+ // ignore
181
+ }
182
+ }
183
+
117
184
  _normalizePath (p) {
118
185
  return p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
119
186
  }
@@ -261,36 +328,11 @@ export class PtyRunner extends EventEmitter {
261
328
  writeLines();
262
329
  this.logger.info(`PTY task sent to ${workDir}: ${task.text.slice(0, 100)}`);
263
330
 
264
- // Monitor PTY output for fatal errors that prevent task completion
265
- const errorPatterns = [
266
- { pattern: 'auto mode temporarily unavailable', msg: 'Claude auto mode temporarily unavailable — retry later' },
267
- { pattern: 'Session expired', msg: 'Claude session expired' },
268
- { pattern: 'Authentication required', msg: 'Claude authentication required' },
269
- ];
270
- const errorCheckInterval = setInterval(() => {
271
- const buf = session._buffer || '';
272
- for (const { pattern, msg } of errorPatterns) {
273
- if (buf.includes(pattern)) {
274
- clearInterval(errorCheckInterval);
275
- this.logger.error(`PTY fatal: ${msg} in ${workDir}`);
276
- if (session._pendingId && this.pendingMarkers.has(session._pendingId)) {
277
- this.pendingMarkers.delete(session._pendingId);
278
- }
279
- session.state = 'idle';
280
- session.currentTask = null;
281
- this._destroyPty(workDir);
282
- this.emit('error', workDir, task, msg);
283
- return;
284
- }
285
- }
286
- }, 3000);
287
-
288
- // Clean up error monitor when task completes normally
289
- const clearErrorCheck = () => clearInterval(errorCheckInterval);
331
+ // Error detection is now handled by the StopFailure hook signal,
332
+ // which writes an error signal file processed by _checkMarkerFiles.
290
333
 
291
334
  // Handle completion asynchronously
292
335
  markerPromise.then((marker) => {
293
- clearErrorCheck();
294
336
  session.state = 'idle';
295
337
  session.currentTask = null;
296
338
  session.sessionId = marker.sessionId;
@@ -312,7 +354,6 @@ export class PtyRunner extends EventEmitter {
312
354
  }
313
355
  this.emit('complete', workDir, task, result);
314
356
  }).catch((err) => {
315
- clearErrorCheck();
316
357
  session.state = 'idle';
317
358
  session.currentTask = null;
318
359
 
@@ -352,8 +393,8 @@ export class PtyRunner extends EventEmitter {
352
393
  // Reduce PTY output noise: disable animations, progress bar, tips
353
394
  if (!args.includes('--settings')) {
354
395
  args.push('--settings', JSON.stringify({
355
- prefersReducedMotion: true,
356
- outputStyle: 'plain',
396
+ // prefersReducedMotion: true,
397
+ // outputStyle: 'plain',
357
398
  terminalProgressBarEnabled: false,
358
399
  spinnerTipsEnabled: false,
359
400
  showTurnDuration: false,
@@ -389,6 +430,9 @@ export class PtyRunner extends EventEmitter {
389
430
  _buffer: '',
390
431
  };
391
432
 
433
+ // Permission auto-approval is now handled by the PermissionRequest hook
434
+ // (returns auto-approve JSON when CLAUDE_NOTIFY_FROM_LISTENER=1).
435
+
392
436
  ptyProcess.onData((data) => {
393
437
  session._buffer += data;
394
438
  // Keep buffer reasonable size
@@ -551,6 +595,44 @@ export class PtyRunner extends EventEmitter {
551
595
  return session?._buffer || '';
552
596
  }
553
597
 
598
+ /**
599
+ * Get last tool activity for a workDir (from PostToolUse hook signals).
600
+ */
601
+ getActivity (workDir) {
602
+ const session = this.sessions.get(workDir);
603
+ return session?._lastActivity || null;
604
+ }
605
+
606
+ /**
607
+ * Clean up activity signal file for a workDir.
608
+ */
609
+ cleanActivitySignal (workDir) {
610
+ const session = this.sessions.get(workDir);
611
+ if (session) {
612
+ session._lastActivity = null;
613
+ }
614
+ // Also try to delete any activity files matching this workDir
615
+ try {
616
+ const files = fs.readdirSync(PTY_SIGNAL_DIR);
617
+ for (const f of files) {
618
+ if (!f.startsWith('act_') || !f.endsWith('.json')) {
619
+ continue;
620
+ }
621
+ const filePath = path.join(PTY_SIGNAL_DIR, f);
622
+ try {
623
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
624
+ if (data.cwd && this._normalizePath(data.cwd) === this._normalizePath(workDir)) {
625
+ fs.unlinkSync(filePath);
626
+ }
627
+ } catch {
628
+ // ignore
629
+ }
630
+ }
631
+ } catch {
632
+ // ignore
633
+ }
634
+ }
635
+
554
636
  /**
555
637
  * Get diagnostic info about a PTY session.
556
638
  */
@@ -0,0 +1,82 @@
1
+ # Notifier Hook Events - Detailed Guide
2
+
3
+ The notifier (`notifier/notifier.js`) is the plugin's hook handler — a single script invoked by Claude Code for every registered hook event. It reads JSON from stdin, determines the event type and operating mode, and dispatches accordingly.
4
+
5
+ ## Operating modes
6
+
7
+ | Mode | Condition | Behavior |
8
+ |---|---|---|
9
+ | **Disabled** | `CLAUDE_NOTIFY_DISABLE=1` | Exit immediately |
10
+ | **Listener-only** | `CLAUDE_NOTIFY_FROM_LISTENER=1` (and `CLAUDE_NOTIFY_AFTER_LISTENER` not `1`) | Write signal files to `~/.claude/pty-signals/`, no user notifications |
11
+ | **Normal** | Default | Send notifications (Telegram, desktop, sound, voice, webhook) |
12
+
13
+ ## Hook events
14
+
15
+ | Hook Event | Mode | Sync | Purpose |
16
+ |---|---|---|---|
17
+ | `UserPromptSubmit` | Normal | sync | Starts the notification timer (records session start time) |
18
+ | `Stop` | Both | sync | Normal: sends completion notification. Listener: writes completion signal file |
19
+ | `StopFailure` | Both | async | Normal: sends error notification. Listener: writes error signal file |
20
+ | `Notification` | Normal | sync | Sends waiting-for-input notification (when `notifyOnWaiting` is enabled) |
21
+ | `SessionStart` | Listener | async | Writes session ready signal (model name, startup/resume source) |
22
+ | `PermissionRequest` | Listener | sync | Auto-approves permission prompts via JSON output to stdout |
23
+ | `PostToolUse` | Listener | async | Writes tool activity signal (tool name, input parameters) |
24
+ | `PostCompact` | Listener | async | Writes context compaction signal (summary, trigger type) |
25
+
26
+ **Sync** hooks block Claude until the script completes (Claude waits for the response).
27
+ **Async** hooks run in the background (Claude continues immediately).
28
+
29
+ ## Normal mode flow
30
+
31
+ ```
32
+ UserPromptSubmit
33
+ → Record session start time in state file
34
+ → Send webhook (if configured)
35
+
36
+ Stop / StopFailure / Notification
37
+ → Check elapsed time since session start
38
+ → Skip if duration < notifyAfterSeconds (default 15s)
39
+ → Build notification text (project, branch, duration, last message)
40
+ → Send: Telegram, desktop toast, sound, voice, webhook
41
+ → Clean up old Telegram messages (deleteAfterHours)
42
+ ```
43
+
44
+ ## Listener-only mode flow
45
+
46
+ ```
47
+ PermissionRequest
48
+ → Output auto-approve JSON to stdout:
49
+ { hookSpecificOutput: { hookEventName: "PermissionRequest",
50
+ decision: { behavior: "allow" } } }
51
+
52
+ Stop
53
+ → Write ~/.claude/pty-signals/{sessionId}.json
54
+ { sessionId, cwd, lastAssistantMessage, cost, numTurns, durationMs }
55
+
56
+ StopFailure
57
+ → Write ~/.claude/pty-signals/err_{sessionId}.json
58
+ { type: "error", cwd, error, errorDetails, lastAssistantMessage }
59
+
60
+ SessionStart
61
+ → Write ~/.claude/pty-signals/rdy_{sessionId}.json
62
+ { type: "ready", cwd, model, source }
63
+
64
+ PostToolUse
65
+ → Write ~/.claude/pty-signals/act_{sessionId}.json (overwritten each call)
66
+ { type: "activity", cwd, toolName, toolInput }
67
+
68
+ PostCompact
69
+ → Write ~/.claude/pty-signals/cmp_{sessionId}.json
70
+ { type: "compact", cwd, summary, trigger }
71
+ ```
72
+
73
+ ## Configuration
74
+
75
+ See the main [README](../README.md) for configuration options.
76
+
77
+ Key settings affecting notifier behavior:
78
+ - `notifyAfterSeconds` (default: 15) — minimum task duration to trigger notifications
79
+ - `notifyOnWaiting` (default: false) — send notifications for idle/waiting events
80
+ - `telegram.includeLastCcMessageInTelegram` (default: true) — include Claude's last message in Telegram notification
81
+ - `telegram.deleteAfterHours` (default: 24) — auto-delete old notification messages
82
+ - `debug` (default: false) — include hook event JSON and trigger type in notifications
@@ -158,26 +158,73 @@ function isNotifierDisabled () {
158
158
  return false;
159
159
  }
160
160
 
161
- function writePtySignalFile (event) {
162
- const sessionId = event.session_id || 'unknown';
163
- const cwd = event.cwd || process.cwd();
161
+ function writeSignalFile (name, data) {
164
162
  try {
165
163
  fs.mkdirSync(PTY_SIGNAL_DIR, { recursive: true });
166
- const signalFile = path.join(PTY_SIGNAL_DIR, `${sessionId}.json`);
167
- fs.writeFileSync(signalFile, JSON.stringify({
168
- sessionId,
169
- cwd,
170
- lastAssistantMessage: event.last_assistant_message || '',
171
- cost: event.total_cost_usd || 0,
172
- numTurns: event.num_turns || 0,
173
- durationMs: event.duration_ms || 0,
174
- timestamp: Date.now(),
175
- }));
164
+ fs.writeFileSync(path.join(PTY_SIGNAL_DIR, name), JSON.stringify(data));
176
165
  } catch {
177
166
  // silent fail
178
167
  }
179
168
  }
180
169
 
170
+ function writePtySignalFile (event) {
171
+ const sessionId = event.session_id || 'unknown';
172
+ writeSignalFile(`${sessionId}.json`, {
173
+ sessionId,
174
+ cwd: event.cwd || process.cwd(),
175
+ lastAssistantMessage: event.last_assistant_message || '',
176
+ cost: event.total_cost_usd || 0,
177
+ numTurns: event.num_turns || 0,
178
+ durationMs: event.duration_ms || 0,
179
+ timestamp: Date.now(),
180
+ });
181
+ }
182
+
183
+ function writeErrorSignalFile (event) {
184
+ const sessionId = event.session_id || 'unknown';
185
+ writeSignalFile(`err_${sessionId}.json`, {
186
+ type: 'error',
187
+ cwd: event.cwd || process.cwd(),
188
+ error: event.error || 'unknown',
189
+ errorDetails: event.error_details || '',
190
+ lastAssistantMessage: event.last_assistant_message || '',
191
+ timestamp: Date.now(),
192
+ });
193
+ }
194
+
195
+ function writeReadySignalFile (event) {
196
+ const sessionId = event.session_id || 'unknown';
197
+ writeSignalFile(`rdy_${sessionId}.json`, {
198
+ type: 'ready',
199
+ cwd: event.cwd || process.cwd(),
200
+ model: event.model || '',
201
+ source: event.source || '',
202
+ timestamp: Date.now(),
203
+ });
204
+ }
205
+
206
+ function writeActivitySignalFile (event) {
207
+ const sessionId = event.session_id || 'unknown';
208
+ writeSignalFile(`act_${sessionId}.json`, {
209
+ type: 'activity',
210
+ cwd: event.cwd || process.cwd(),
211
+ toolName: event.tool_name || '',
212
+ toolInput: event.tool_input || {},
213
+ timestamp: Date.now(),
214
+ });
215
+ }
216
+
217
+ function writeCompactSignalFile (event) {
218
+ const sessionId = event.session_id || 'unknown';
219
+ writeSignalFile(`cmp_${sessionId}.json`, {
220
+ type: 'compact',
221
+ cwd: event.cwd || process.cwd(),
222
+ summary: event.compact_summary || '',
223
+ trigger: event.trigger || '',
224
+ timestamp: Date.now(),
225
+ });
226
+ }
227
+
181
228
  // ----------------------
182
229
  // STATE FILE
183
230
  // ----------------------
@@ -659,10 +706,32 @@ process.stdin.on('end', async () => {
659
706
  process.exit(0);
660
707
  }
661
708
 
662
- // For listener-only mode: write PTY signal file on Stop, then exit
709
+ // For listener-only mode: handle events via signal files, then exit
663
710
  if (disabled === 'listener-only') {
664
- if (eventType === 'Stop') {
665
- writePtySignalFile(event);
711
+ switch (eventType) {
712
+ case 'PermissionRequest':
713
+ process.stdout.write(JSON.stringify({
714
+ hookSpecificOutput: {
715
+ hookEventName: 'PermissionRequest',
716
+ decision: { behavior: 'allow' },
717
+ },
718
+ }));
719
+ break;
720
+ case 'Stop':
721
+ writePtySignalFile(event);
722
+ break;
723
+ case 'StopFailure':
724
+ writeErrorSignalFile(event);
725
+ break;
726
+ case 'SessionStart':
727
+ writeReadySignalFile(event);
728
+ break;
729
+ case 'PostToolUse':
730
+ writeActivitySignalFile(event);
731
+ break;
732
+ case 'PostCompact':
733
+ writeCompactSignalFile(event);
734
+ break;
666
735
  }
667
736
  process.exit(0);
668
737
  }
@@ -691,7 +760,7 @@ process.stdin.on('end', async () => {
691
760
  // STOP / NOTIFICATION EVENT
692
761
  // ----------------------
693
762
 
694
- if (eventType !== 'Stop' && eventType !== 'Notification') {
763
+ if (eventType !== 'Stop' && eventType !== 'Notification' && eventType !== 'StopFailure') {
695
764
  process.exit(0);
696
765
  }
697
766
 
@@ -709,8 +778,8 @@ process.stdin.on('end', async () => {
709
778
  process.exit(0);
710
779
  }
711
780
 
712
- const statusEmoji = eventType === 'Notification' ? '⏸' : '✅';
713
- const desktopStatus = eventType === 'Notification' ? 'Waiting' : 'Finished';
781
+ const statusEmoji = eventType === 'Notification' ? '⏸' : eventType === 'StopFailure' ? '❌' : '✅';
782
+ const desktopStatus = eventType === 'Notification' ? 'Waiting' : eventType === 'StopFailure' ? `Error: ${event.error || 'unknown'}` : 'Finished';
714
783
 
715
784
  const branch = getBranch(cwd);
716
785
  let label = `/${project}`;
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.49",
4
+ "version": "1.1.55",
5
5
  "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
6
  "type": "module",
7
7
  "engines": {