claude-notification-plugin 1.1.52 → 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,65 +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
- // Some patterns (e.g. "auto mode temporarily unavailable") can appear in the
266
- // Claude CLI status bar while Claude is still actively working. To avoid false
267
- // positives we require the buffer to stop growing for two consecutive checks
268
- // before treating the match as a real error.
269
- const errorPatterns = [
270
- { pattern: 'auto mode temporarily unavailable', msg: 'Claude auto mode temporarily unavailable — retry later' },
271
- { pattern: 'Session expired', msg: 'Claude session expired' },
272
- { pattern: 'Authentication required', msg: 'Claude authentication required' },
273
- ];
274
- let errorCandidate = null; // { msg, bufLen } — suspected error awaiting confirmation
275
- let checkedUpTo = 0; // buffer offset already scanned — avoids re-matching the same text
276
- const errorCheckInterval = setInterval(() => {
277
- const buf = session._buffer || '';
278
- const bufLen = buf.length;
279
-
280
- // If we have a candidate from the previous cycle, confirm it
281
- if (errorCandidate) {
282
- if (bufLen === errorCandidate.bufLen) {
283
- // Buffer did not grow — Claude stopped, treat as real error
284
- clearInterval(errorCheckInterval);
285
- this.logger.error(`PTY fatal: ${errorCandidate.msg} in ${workDir}`);
286
- if (session._pendingId && this.pendingMarkers.has(session._pendingId)) {
287
- this.pendingMarkers.delete(session._pendingId);
288
- }
289
- session.state = 'idle';
290
- session.currentTask = null;
291
- this._destroyPty(workDir);
292
- this.emit('error', workDir, task, errorCandidate.msg);
293
- return;
294
- }
295
- // Buffer grew — Claude is still working, false positive
296
- this.logger.info(`PTY error candidate dismissed (buffer grew) in ${workDir}`);
297
- errorCandidate = null;
298
- checkedUpTo = bufLen;
299
- return;
300
- }
301
-
302
- // Scan only new buffer content for pattern matches
303
- if (bufLen <= checkedUpTo) {
304
- return;
305
- }
306
- const fresh = buf.slice(checkedUpTo);
307
- for (const { pattern, msg } of errorPatterns) {
308
- if (fresh.includes(pattern)) {
309
- this.logger.warn(`PTY error candidate detected: "${pattern}" in ${workDir}, waiting to confirm…`);
310
- errorCandidate = { msg, bufLen };
311
- return;
312
- }
313
- }
314
- checkedUpTo = bufLen;
315
- }, 3000);
316
-
317
- // Clean up error monitor when task completes normally
318
- 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.
319
333
 
320
334
  // Handle completion asynchronously
321
335
  markerPromise.then((marker) => {
322
- clearErrorCheck();
323
336
  session.state = 'idle';
324
337
  session.currentTask = null;
325
338
  session.sessionId = marker.sessionId;
@@ -341,7 +354,6 @@ export class PtyRunner extends EventEmitter {
341
354
  }
342
355
  this.emit('complete', workDir, task, result);
343
356
  }).catch((err) => {
344
- clearErrorCheck();
345
357
  session.state = 'idle';
346
358
  session.currentTask = null;
347
359
 
@@ -381,8 +393,8 @@ export class PtyRunner extends EventEmitter {
381
393
  // Reduce PTY output noise: disable animations, progress bar, tips
382
394
  if (!args.includes('--settings')) {
383
395
  args.push('--settings', JSON.stringify({
384
- prefersReducedMotion: true,
385
- outputStyle: 'plain',
396
+ // prefersReducedMotion: true,
397
+ // outputStyle: 'plain',
386
398
  terminalProgressBarEnabled: false,
387
399
  spinnerTipsEnabled: false,
388
400
  showTurnDuration: false,
@@ -418,44 +430,18 @@ export class PtyRunner extends EventEmitter {
418
430
  _buffer: '',
419
431
  };
420
432
 
421
- // Auto-answer permission prompts: track buffer position and cooldown to
422
- // prevent double-fire when Claude re-renders the same prompt on screen.
423
- let lastPermissionAnswerAt = 0;
424
- let lastPermissionAnswerTime = 0;
425
- const PERMISSION_COOLDOWN_MS = 5000;
433
+ // Permission auto-approval is now handled by the PermissionRequest hook
434
+ // (returns auto-approve JSON when CLAUDE_NOTIFY_FROM_LISTENER=1).
426
435
 
427
436
  ptyProcess.onData((data) => {
428
437
  session._buffer += data;
429
438
  // Keep buffer reasonable size
430
439
  if (session._buffer.length > 50000) {
431
440
  session._buffer = session._buffer.slice(-25000);
432
- lastPermissionAnswerAt = Math.max(0, lastPermissionAnswerAt - 25000);
433
441
  }
434
442
  if (session._logStream) {
435
443
  session._logStream.write(data);
436
444
  }
437
-
438
- // Auto-answer interactive permission prompts that appear when
439
- // "auto mode temporarily unavailable". The prompt looks like:
440
- // Do you want to proceed?
441
- // ❯ 1. Yes
442
- // 2. Yes, and always allow …
443
- // 3. No
444
- // We send Enter to approve (cursor ❯ is already on "1. Yes").
445
- // Cooldown prevents double-fire from screen redraws.
446
- const now = Date.now();
447
- if (session.state === 'busy'
448
- && session._buffer.length - lastPermissionAnswerAt > 50
449
- && now - lastPermissionAnswerTime > PERMISSION_COOLDOWN_MS) {
450
- const tail = session._buffer.slice(lastPermissionAnswerAt);
451
- const clean = tail.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/\s/g, '');
452
- if (clean.includes('Doyouwanttoproceed') && clean.includes('1.Yes')) {
453
- lastPermissionAnswerAt = session._buffer.length;
454
- lastPermissionAnswerTime = now;
455
- this.logger.info(`Auto-answering permission prompt in ${session.workDir}`);
456
- session.pty.write('\r');
457
- }
458
- }
459
445
  });
460
446
 
461
447
  ptyProcess.onExit(({ exitCode }) => {
@@ -609,6 +595,44 @@ export class PtyRunner extends EventEmitter {
609
595
  return session?._buffer || '';
610
596
  }
611
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
+
612
636
  /**
613
637
  * Get diagnostic info about a PTY session.
614
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.52",
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": {