claude-notification-plugin 1.1.104 → 1.1.108

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,20 +1,20 @@
1
- {
2
- "name": "claude-notification-plugin",
3
- "version": "1.1.104",
4
- "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
5
- "author": {
6
- "name": "Viacheslav Makarov",
7
- "email": "npmjs@bazilio.ru"
8
- },
9
- "homepage": "https://github.com/Bazilio-san/claude-notification-plugin#readme",
10
- "repository": "https://github.com/Bazilio-san/claude-notification-plugin",
11
- "license": "MIT",
12
- "keywords": [
13
- "notification",
14
- "telegram",
15
- "windows",
16
- "sound",
17
- "voice",
18
- "hooks"
19
- ]
20
- }
1
+ {
2
+ "name": "claude-notification-plugin",
3
+ "version": "1.1.108",
4
+ "description": "Telegram listener daemon + Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
5
+ "author": {
6
+ "name": "Viacheslav Makarov",
7
+ "email": "npmjs@bazilio.ru"
8
+ },
9
+ "homepage": "https://github.com/Bazilio-san/claude-notification-plugin#readme",
10
+ "repository": "https://github.com/Bazilio-san/claude-notification-plugin",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "notification",
14
+ "telegram",
15
+ "windows",
16
+ "sound",
17
+ "voice",
18
+ "hooks"
19
+ ]
20
+ }
package/README.md CHANGED
@@ -2,11 +2,23 @@
2
2
 
3
3
  **Send a message in Telegram, and the task starts running on your PC.**
4
4
 
5
- Cross-platform notifications for Claude Code task completion.
6
- Sends alerts to Telegram and desktop (Windows, macOS, Linux) when Claude finishes working.
7
-
8
-
9
- ## Features
5
+ Cross-platform notifications for Claude Code task completion.
6
+ Sends alerts to Telegram and desktop (Windows, macOS, Linux) when Claude finishes working.
7
+
8
+ ## Start Here (Listener)
9
+
10
+ If you want Telegram-first remote control, start with the Listener daemon:
11
+
12
+ ```bash
13
+ claude-notify listener setup
14
+ claude-notify listener start
15
+ claude-notify listener status
16
+ ```
17
+
18
+ Deep internals and troubleshooting: [Detailed Guide](listener/LISTENER-DETAILED.md)
19
+
20
+
21
+ ## Features
10
22
 
11
23
  - **[Telegram Listener](#telegram-listener)** — your remote control for Claude (supports worktrees)
12
24
  - Telegram bot messages with auto-delete
@@ -118,7 +130,7 @@ ENV: `CLAUDE_NOTIFY_TELEGRAM_TOKEN`
118
130
  **telegram.chatId** — Chat ID to send messages to.
119
131
  ENV: `CLAUDE_NOTIFY_TELEGRAM_CHAT_ID`
120
132
 
121
- **telegram.deleteAfterHours** — Auto-delete old Telegram messages after N hours. `0` to disable. Default: **24**
133
+ **telegram.deleteAfterHours** — Auto-delete old Telegram messages after N hours (applies to notifier and listener bot messages). `0` to disable. Default: **24**
122
134
 
123
135
  **telegram.includeLastCcMessageInTelegram** — Append Claude's last message to the notification (truncated to 3500 chars). Default: **true**
124
136
  ENV: `CLAUDE_NOTIFY_INCLUDE_LAST_CC_MESSAGE_IN_TELEGRAM`
@@ -219,14 +231,16 @@ fix the login bug → runs in "default" project
219
231
  &api/feature/auth implement OAuth2 → runs in a worktree (auto-created)
220
232
  ```
221
233
 
222
- The bot replies with status and results:
234
+ The bot replies with status and results:
223
235
 
224
236
  ```
225
237
  ⏳ [&api] Running: add pagination to GET /users
226
238
  ...
227
- ✅ [&api] Done: add pagination to GET /users
228
- <claude's output>
229
- ```
239
+ ✅ [&api] Done: add pagination to GET /users
240
+ <claude's output>
241
+ ```
242
+
243
+ If Claude returns a temporary API failure (`StopFailure`, e.g. `529 overloaded`), the listener saves the reported session ID and the next task for the same target auto-resumes it.
230
244
 
231
245
  ### 4. Manage the daemon
232
246
 
package/bin/cli.js CHANGED
@@ -32,10 +32,11 @@ switch (command) {
32
32
  console.log(`Usage: claude-notify <command> [options]
33
33
 
34
34
  Commands:
35
+ listener <action> Manage the Telegram Listener daemon
36
+ Actions: start, stop, status, logs, restart
35
37
  install Setup plugin registration, Telegram config, hooks
36
38
  uninstall Remove plugin, hooks, config, CLI wrappers
37
- listener <action> Manage the Telegram Listener daemon
38
- Actions: start, stop, status, logs, restart`);
39
+ `);
39
40
  process.exit(command ? 1 : 0);
40
41
  }
41
42
  }
package/bin/install.js CHANGED
@@ -737,11 +737,16 @@ Plugin hooks (via hooks/hooks.json):
737
737
  Config: ${CONFIG_PATH}
738
738
  ${telegramStatus}${platformTip}
739
739
 
740
- Log: ${INSTALL_LOG_PATH}
741
-
742
- To uninstall: claude-notify uninstall
743
-
744
- To disable per project, add to .claude/settings.local.json: { "env": { "CLAUDE_NOTIFY_DISABLE": "1" } }`);
740
+ Log: ${INSTALL_LOG_PATH}
741
+
742
+ Listener quick start:
743
+ claude-notify listener setup
744
+ claude-notify listener start
745
+ claude-notify listener status
746
+
747
+ To uninstall: claude-notify uninstall
748
+
749
+ To disable per project, add to .claude/settings.local.json: { "env": { "CLAUDE_NOTIFY_DISABLE": "1" } }`);
745
750
 
746
751
  closeLog();
747
752
  }
package/commit-sha CHANGED
@@ -1 +1 @@
1
- 6fb63d8cf4e4026a2909a47213b4303438712d8f
1
+ 0daaa9a945591246c6de6c117d9cf9b4add7ab25
@@ -3,7 +3,7 @@
3
3
  Telegram Listener is a background daemon that receives tasks from a Telegram chat
4
4
  and executes them on your machine via an interactive Claude Code PTY session. The result is sent back to Telegram.
5
5
 
6
- **[Quick Start here](../LISTENER.md)**
6
+ **[Quick Start here](../README.md#telegram-listener)**
7
7
 
8
8
  # Detailed Guide
9
9
 
@@ -133,7 +133,9 @@ const resumeLastSessionEnabled = listenerConfig.resumeLastSession !== false; //
133
133
  const sessionsListLimit = listenerConfig.sessionsListLimit || 5;
134
134
  const sessionWorkingThresholdSec = listenerConfig.sessionWorkingThresholdSec || 2;
135
135
 
136
- const poller = new TelegramPoller(token, chatId, logger);
136
+ const poller = new TelegramPoller(token, chatId, logger, {
137
+ deleteAfterHours: config.telegram?.deleteAfterHours,
138
+ });
137
139
  const queue = new WorkQueue(
138
140
  logger,
139
141
  listenerConfig.maxQueuePerWorkDir || 10,
@@ -298,25 +300,39 @@ async function notifyTaskCompletion (workDir, task, kind, payload = {}) {
298
300
  runner.cleanActivitySignal(workDir);
299
301
  const entry = queue.queues[workDir];
300
302
  const label = formatLabel(entry?.project, entry?.branch);
301
- const output = payload.text || '';
303
+ let output = payload.text || '';
302
304
 
303
305
  // Build header
304
306
  let header;
305
307
  let queueResult;
306
308
  if (kind === 'error') {
307
- header = `❌ <code>${label}</code>\nError`;
309
+ const resumeSid = payload.resumeSessionId || payload.sessionId || null;
310
+ if (resumeSid) {
311
+ setStoredSessionId(workDir, resumeSid);
312
+ }
313
+ const resumeHint = resumeSid
314
+ ? `\nSaved session: <code>${resumeSid}</code>\nNext task for this target will auto-resume it.`
315
+ : '';
316
+ header = `❌ <code>${label}</code>\nError${resumeHint}`;
308
317
  queueResult = `ERROR: ${payload.errorMsg}`;
309
318
  } else if (kind === 'timeout') {
310
319
  const reason = payload.reason || `no activity for ${payload.timeoutMin} min`;
311
320
  header = `⏰ <code>${label}</code>\nTask forcefully stopped — ${reason}`;
312
321
  queueResult = 'TIMEOUT';
313
322
  } else if (task.raw) {
314
- // /clear wipes Claude's context — reset our counters to match.
315
- if ((task.text || '').trim().toLowerCase() === '/clear') {
323
+ const rawCmd = (task.text || '').trim().toLowerCase();
324
+ // /clear wipes Claude's context — reset our counters to match. The PTY
325
+ // buffer after /clear is just an empty prompt + status bar, so skip the
326
+ // body dump and report a clean confirmation instead.
327
+ if (rawCmd === '/clear') {
316
328
  sessions.delete(workDir);
329
+ header = `🧹 <code>${label}</code> <code>/clear</code> — session reset`;
330
+ queueResult = '/clear';
331
+ output = '';
332
+ } else {
333
+ header = `📨 <code>${label}</code> sent <code>${escapeHtml(task.text)}</code>`;
334
+ queueResult = output;
317
335
  }
318
- header = `📨 <code>${label}</code> sent <code>${escapeHtml(task.text)}</code>`;
319
- queueResult = output;
320
336
  } else {
321
337
  // Update session tracking for non-raw completions
322
338
  const session = sessions.get(workDir) || { taskCount: 0 };
@@ -406,7 +422,17 @@ async function notifyTaskCompletion (workDir, task, kind, payload = {}) {
406
422
  }
407
423
 
408
424
  runner.on('complete', (workDir, task, result) => notifyTaskCompletion(workDir, task, 'complete', result));
409
- runner.on('error', (workDir, task, errorMsg) => notifyTaskCompletion(workDir, task, 'error', { errorMsg }));
425
+ runner.on('error', (workDir, task, errorData) => {
426
+ if (typeof errorData === 'string') {
427
+ notifyTaskCompletion(workDir, task, 'error', { errorMsg: errorData });
428
+ return;
429
+ }
430
+ notifyTaskCompletion(workDir, task, 'error', {
431
+ errorMsg: errorData?.message || 'Unknown error',
432
+ sessionId: errorData?.sessionId || null,
433
+ resumeSessionId: errorData?.resumeSessionId || null,
434
+ });
435
+ });
410
436
  runner.on('timeout', (workDir, task) => notifyTaskCompletion(workDir, task, 'timeout', {
411
437
  timeoutMin: Math.round(taskTimeout / 60000),
412
438
  }));
@@ -4,7 +4,8 @@ import fs from 'fs';
4
4
  import path from 'path';
5
5
  import { EventEmitter } from 'events';
6
6
  import { PTY_SIGNAL_DIR } from '../bin/constants.js';
7
- import { cleanPtyOutput } from './telegram-poller.js';
7
+ import { cleanRenderedScreen } from './telegram-poller.js';
8
+ import { renderPtyScreen } from './screen-renderer.js';
8
9
 
9
10
  const DEFAULT_TIMEOUT = 600_000; // 10 minutes
10
11
  // Built-in slash-commands (forwarded via %cmd) rarely emit a Stop hook event,
@@ -153,6 +154,8 @@ export class PtyRunner extends EventEmitter {
153
154
  } else if (type === 'error') {
154
155
  // StopFailure — emit error, abort task
155
156
  this._unlinkSafe(filePath);
157
+ const errorSignalSessionId = marker.sessionId
158
+ || (f.startsWith('err_') ? f.slice(4, -5) : null);
156
159
  for (const [workDir, session] of this.sessions) {
157
160
  if (session.state === 'busy' && this._normalizePath(session.workDir) === this._normalizePath(cwd)) {
158
161
  if (session._pendingId && this.pendingMarkers.has(session._pendingId)) {
@@ -163,11 +166,22 @@ export class PtyRunner extends EventEmitter {
163
166
  session.currentTask = null;
164
167
  this._destroyPty(workDir);
165
168
  const errorMsg = `API error: ${marker.error}${marker.errorDetails ? ' — ' + marker.errorDetails : ''}`;
169
+ const resumeMatch = (marker.errorDetails || marker.lastAssistantMessage || '')
170
+ .match(/(?:^|\s)--resume\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
171
+ const resumeSessionId = resumeMatch?.[1] || null;
166
172
  this.logger.error(`Hook signal: ${errorMsg} in ${workDir}`);
167
173
  if (this.taskLogger) {
168
174
  this.taskLogger.logAnswer(task?.project || 'unknown', task?.branch || 'main', errorMsg, 1);
169
175
  }
170
- this.emit('error', workDir, task, errorMsg);
176
+ this.emit('error', workDir, task, {
177
+ message: errorMsg,
178
+ sessionId: errorSignalSessionId && errorSignalSessionId !== 'unknown'
179
+ ? errorSignalSessionId
180
+ : null,
181
+ resumeSessionId,
182
+ errorType: marker.error || 'unknown',
183
+ errorDetails: marker.errorDetails || '',
184
+ });
171
185
  break;
172
186
  }
173
187
  }
@@ -253,31 +267,37 @@ export class PtyRunner extends EventEmitter {
253
267
  }
254
268
 
255
269
  const CHECK_INTERVAL = 5000;
256
- const checker = setInterval(() => {
270
+ let idleCheckInFlight = false;
271
+ const checker = setInterval(async () => {
257
272
  const lastActivity = session?._lastActivityTime || 0;
258
273
  const idleMs = lastActivity > 0 ? Date.now() - lastActivity : 0;
259
274
 
260
275
  // Idle-prompt fallback: PTY went quiet AND its tail shows Claude's
261
276
  // idle status bar — treat as completed even if the Stop hook never
262
277
  // produced a signal (happens with resumed sessions on Windows ConPTY).
263
- if (idleMs > IDLE_PROMPT_FALLBACK_MS && session) {
264
- const tailRaw = (session._buffer || '').slice(-3000);
265
- const tailClean = cleanPtyOutput(tailRaw);
266
- if (IDLE_PROMPT_RE.test(tailClean)) {
267
- clearInterval(checker);
268
- this.pendingMarkers.delete(pendingId);
269
- const fullClean = cleanPtyOutput(session._buffer || '').trim();
270
- const text = fullClean.length > 2000 ? fullClean.slice(-2000) : fullClean;
271
- this.logger.warn(`PTY idle-prompt fallback completion in ${session.workDir} (no Stop signal in ${Math.round(idleMs / 1000)}s)`);
272
- resolve({
273
- lastAssistantMessage: text,
274
- sessionId: session.sessionId || null,
275
- cost: 0,
276
- numTurns: 0,
277
- durationMs: idleMs,
278
- isIdleFallback: true,
279
- });
280
- return;
278
+ // Rendering is async (xterm-headless), so guard against re-entry while
279
+ // an earlier tick is still rendering.
280
+ if (idleMs > IDLE_PROMPT_FALLBACK_MS && session && !idleCheckInFlight) {
281
+ idleCheckInFlight = true;
282
+ try {
283
+ const rendered = await renderPtyScreen(session._buffer || '');
284
+ if (IDLE_PROMPT_RE.test(rendered)) {
285
+ clearInterval(checker);
286
+ this.pendingMarkers.delete(pendingId);
287
+ const text = cleanRenderedScreen(rendered).trim().slice(-2000);
288
+ this.logger.warn(`PTY idle-prompt fallback completion in ${session.workDir} (no Stop signal in ${Math.round(idleMs / 1000)}s)`);
289
+ resolve({
290
+ lastAssistantMessage: text,
291
+ sessionId: session.sessionId || null,
292
+ cost: 0,
293
+ numTurns: 0,
294
+ durationMs: idleMs,
295
+ isIdleFallback: true,
296
+ });
297
+ return;
298
+ }
299
+ } finally {
300
+ idleCheckInFlight = false;
281
301
  }
282
302
  }
283
303
 
@@ -437,7 +457,7 @@ export class PtyRunner extends EventEmitter {
437
457
  this.taskLogger.logAnswer(task.project || 'unknown', task.branch || 'main', result.text, 0);
438
458
  }
439
459
  this.emit('complete', workDir, task, result);
440
- }).catch((err) => {
460
+ }).catch(async (err) => {
441
461
  session.state = 'idle';
442
462
  session.currentTask = null;
443
463
 
@@ -445,7 +465,11 @@ export class PtyRunner extends EventEmitter {
445
465
  if (task.raw) {
446
466
  // Slash commands (e.g. /clear, /cost) usually don't emit a Stop hook.
447
467
  // Treat inactivity as successful completion and keep the PTY alive.
448
- const cleaned = cleanPtyOutput(session._buffer || '').trim();
468
+ // Render through xterm-headless so the *final* screen is what we
469
+ // report — naive ANSI stripping leaves transient popup rows in the
470
+ // output even though the real terminal has redrawn over them.
471
+ const rendered = await renderPtyScreen(session._buffer || '');
472
+ const cleaned = cleanRenderedScreen(rendered).trim();
449
473
  const tail = cleaned.length > 2000 ? cleaned.slice(-2000) : cleaned;
450
474
  const result = {
451
475
  text: tail,
@@ -0,0 +1,41 @@
1
+ import xtermPkg from '@xterm/headless';
2
+
3
+ const { Terminal } = xtermPkg;
4
+
5
+ // Must match the dimensions PTY sessions are spawned with (pty-runner.js).
6
+ // Mismatch produces wrap artifacts in the rendered viewport.
7
+ const COLS = 120;
8
+ const ROWS = 40;
9
+
10
+ // Run raw PTY bytes through a headless xterm.js so the *final* visible screen
11
+ // is what we read out — cursor positioning, line erases, scroll-up, etc. are
12
+ // all honored. Without this, transient UI (e.g. the slash-command popup that
13
+ // briefly appears while typing `/clear`) leaks into the captured output even
14
+ // though the real terminal has long since cleared those rows.
15
+ //
16
+ // Returns the viewport text (ROWS lines joined by \n) with trailing empty
17
+ // lines trimmed. Scrollback is intentionally ignored — Telegram messages
18
+ // reflect the on-screen state, not the entire session history.
19
+ export async function renderPtyScreen (raw) {
20
+ if (!raw) {
21
+ return '';
22
+ }
23
+ const term = new Terminal({ cols: COLS, rows: ROWS, allowProposedApi: true });
24
+ await new Promise((resolve) => term.write(raw, resolve));
25
+ const buf = term.buffer.active;
26
+ const lines = [];
27
+ const startY = buf.viewportY;
28
+ for (let y = 0; y < ROWS; y++) {
29
+ const line = buf.getLine(startY + y);
30
+ lines.push(line ? line.translateToString(true) : '');
31
+ }
32
+ while (lines.length && !lines[lines.length - 1].trim()) {
33
+ lines.pop();
34
+ }
35
+ if (typeof term.dispose === 'function') {
36
+ term.dispose();
37
+ }
38
+ return lines.join('\n');
39
+ }
40
+
41
+ export { COLS as RENDER_COLS, ROWS as RENDER_ROWS };
@@ -7,17 +7,22 @@ const MAX_MESSAGE_LENGTH = 4096;
7
7
  // of looping silently.
8
8
  const MAX_CONSECUTIVE_409 = 8;
9
9
 
10
- export class TelegramPoller {
11
- constructor (token, chatId, logger) {
12
- this.token = token;
13
- this.chatId = String(chatId);
14
- this.logger = logger;
15
- this.baseUrl = `https://api.telegram.org/bot${token}`;
16
- this.offset = 0;
17
- this._errorBackoff = 0; // current backoff in ms (0 = no backoff)
18
- this._consecutiveErrors = 0;
19
- this._consecutive409 = 0;
20
- }
10
+ export class TelegramPoller {
11
+ constructor (token, chatId, logger, options = {}) {
12
+ this.token = token;
13
+ this.chatId = String(chatId);
14
+ this.logger = logger;
15
+ this.baseUrl = `https://api.telegram.org/bot${token}`;
16
+ this.offset = 0;
17
+ this._errorBackoff = 0; // current backoff in ms (0 = no backoff)
18
+ this._consecutiveErrors = 0;
19
+ this._consecutive409 = 0;
20
+ const deleteAfterHours = Number(options.deleteAfterHours);
21
+ this._deleteAfterMs = Number.isFinite(deleteAfterHours) && deleteAfterHours > 0
22
+ ? deleteAfterHours * 3600_000
23
+ : 0;
24
+ this._sentMessages = [];
25
+ }
21
26
 
22
27
  async flush () {
23
28
  try {
@@ -153,9 +158,12 @@ export class TelegramPoller {
153
158
  body: JSON.stringify({ ...base, parse_mode: 'HTML' }),
154
159
  });
155
160
  let data = await res.json();
156
- if (data.ok) {
157
- return data.result.message_id;
158
- }
161
+ if (data.ok) {
162
+ const messageId = data.result.message_id;
163
+ this._trackSentMessage(messageId);
164
+ await this._cleanupOldMessages();
165
+ return messageId;
166
+ }
159
167
  const htmlErr = data.description || `error_code ${data.error_code}`;
160
168
  // Retry without HTML parse mode (covers entity-parsing errors)
161
169
  res = await fetch(`${this.baseUrl}/sendMessage`, {
@@ -164,10 +172,13 @@ export class TelegramPoller {
164
172
  body: JSON.stringify(base),
165
173
  });
166
174
  data = await res.json();
167
- if (data.ok) {
168
- this.logger.warn(`sendMessage: HTML failed (${htmlErr}), plain succeeded`);
169
- return data.result.message_id;
170
- }
175
+ if (data.ok) {
176
+ this.logger.warn(`sendMessage: HTML failed (${htmlErr}), plain succeeded`);
177
+ const messageId = data.result.message_id;
178
+ this._trackSentMessage(messageId);
179
+ await this._cleanupOldMessages();
180
+ return messageId;
181
+ }
171
182
  this.logger.error(`sendMessage failed: HTML=${htmlErr}, plain=${data.description || data.error_code}`);
172
183
  return null;
173
184
  } catch (err) {
@@ -262,23 +273,57 @@ export class TelegramPoller {
262
273
  }
263
274
  }
264
275
 
265
- async sendDocument (buffer, filename, caption) {
266
- try {
267
- const formData = new FormData();
268
- formData.append('chat_id', this.chatId);
269
- formData.append('document', new Blob([buffer]), filename);
270
- if (caption) {
271
- formData.append('caption', caption.slice(0, 1024));
272
- }
273
- await fetch(`${this.baseUrl}/sendDocument`, {
274
- method: 'POST',
275
- body: formData,
276
- });
277
- } catch (err) {
278
- this.logger.error(`sendDocument error: ${err.message}`);
279
- }
280
- }
281
- }
276
+ async sendDocument (buffer, filename, caption) {
277
+ try {
278
+ const formData = new FormData();
279
+ formData.append('chat_id', this.chatId);
280
+ formData.append('document', new Blob([buffer]), filename);
281
+ if (caption) {
282
+ formData.append('caption', caption.slice(0, 1024));
283
+ }
284
+ const res = await fetch(`${this.baseUrl}/sendDocument`, {
285
+ method: 'POST',
286
+ body: formData,
287
+ });
288
+ const data = await res.json();
289
+ if (data.ok && data.result?.message_id) {
290
+ this._trackSentMessage(data.result.message_id);
291
+ await this._cleanupOldMessages();
292
+ }
293
+ } catch (err) {
294
+ this.logger.error(`sendDocument error: ${err.message}`);
295
+ }
296
+ }
297
+
298
+ _trackSentMessage (messageId) {
299
+ if (!messageId || this._deleteAfterMs <= 0) {
300
+ return;
301
+ }
302
+ this._sentMessages.push({
303
+ id: messageId,
304
+ ts: Date.now(),
305
+ });
306
+ if (this._sentMessages.length > 1000) {
307
+ this._sentMessages = this._sentMessages.slice(-500);
308
+ }
309
+ }
310
+
311
+ async _cleanupOldMessages () {
312
+ if (this._deleteAfterMs <= 0 || this._sentMessages.length === 0) {
313
+ return;
314
+ }
315
+ const now = Date.now();
316
+ const keep = [];
317
+ for (const msg of this._sentMessages) {
318
+ if (now - msg.ts > this._deleteAfterMs) {
319
+ await this.deleteMessage(msg.id);
320
+ } else {
321
+ keep.push(msg);
322
+ }
323
+ }
324
+ this._sentMessages = keep;
325
+ }
326
+ }
282
327
 
283
328
  function escapeHtml (text) {
284
329
  return text
@@ -416,4 +461,54 @@ function cleanPtyOutput (raw) {
416
461
  return cleaned.join('\n');
417
462
  }
418
463
 
419
- export { escapeHtml, stripAnsi, cleanPtyOutput };
464
+ // Softer cleaner for *rendered* terminal screens (post xterm-headless emulation).
465
+ // Unlike cleanPtyOutput, this preserves `❯ <command>` lines because the
466
+ // rendered screen already reflects the final state — that prompt line is the
467
+ // actual command echo + result, not a transient input being typed.
468
+ function cleanRenderedScreen (rendered) {
469
+ if (!rendered) {
470
+ return '';
471
+ }
472
+ const lines = rendered.split('\n');
473
+ const cleaned = [];
474
+ for (const line of lines) {
475
+ const trimmed = line.trimEnd();
476
+ if (!trimmed.trim()) {
477
+ continue;
478
+ }
479
+ // Empty prompt arrow (no command on it) — drop
480
+ if (/^❯\s*$/.test(trimmed)) {
481
+ continue;
482
+ }
483
+ // Pure divider lines (≥50% box-drawing chars)
484
+ const specialChars = (trimmed.match(/[▐▝▘▛▜█▌▀▄░▒▓─━═╌┄│┃┌┐└┘├┤┬┴┼╔╗╚╝╠╣╦╩╬]/g) || []).length;
485
+ if (specialChars > trimmed.length * 0.5) {
486
+ continue;
487
+ }
488
+ // Status bar — bypass/auto/plan permissions
489
+ if (/[⏵⏴]\s*[⏵⏴]?\s*(bypass|auto|plan)\s+permissions?/i.test(trimmed)) {
490
+ continue;
491
+ }
492
+ if (/shift\+tab\s*to\s*cycle/i.test(trimmed)) {
493
+ continue;
494
+ }
495
+ if (/ctrl\+[a-z]\s+to\s/i.test(trimmed)) {
496
+ continue;
497
+ }
498
+ // Effort indicator: "◉ xhigh · /effort"
499
+ if (/^\s*◉\s+\S+\s*·\s*\/effort\s*$/.test(trimmed)) {
500
+ continue;
501
+ }
502
+ // Help hint: "/ide for WebStorm" etc. at far-right column
503
+ if (/^\s+\/(ide|model|help)\s+(for|to)\s+/i.test(trimmed)) {
504
+ continue;
505
+ }
506
+ if (/Pasting\s*text/i.test(trimmed)) {
507
+ continue;
508
+ }
509
+ cleaned.push(trimmed);
510
+ }
511
+ return cleaned.join('\n');
512
+ }
513
+
514
+ export { escapeHtml, stripAnsi, cleanPtyOutput, cleanRenderedScreen };
@@ -214,15 +214,16 @@ function writePtySignalFile (event) {
214
214
  });
215
215
  }
216
216
 
217
- function writeErrorSignalFile (event) {
218
- const sessionId = event.session_id || 'unknown';
219
- writeSignalFile(`err_${sessionId}.json`, {
220
- type: 'error',
221
- cwd: event.cwd || process.cwd(),
222
- error: event.error || 'unknown',
223
- errorDetails: event.error_details || '',
224
- lastAssistantMessage: event.last_assistant_message || '',
225
- timestamp: Date.now(),
217
+ function writeErrorSignalFile (event) {
218
+ const sessionId = event.session_id || 'unknown';
219
+ writeSignalFile(`err_${sessionId}.json`, {
220
+ type: 'error',
221
+ sessionId,
222
+ cwd: event.cwd || process.cwd(),
223
+ error: event.error || 'unknown',
224
+ errorDetails: event.error_details || '',
225
+ lastAssistantMessage: event.last_assistant_message || '',
226
+ timestamp: Date.now(),
226
227
  });
227
228
  }
228
229
 
@@ -413,8 +414,11 @@ async function sendTelegram (config, state) {
413
414
  }
414
415
 
415
416
  // Delete old messages
416
- const maxAge = (config.telegram.deleteAfterHours || 24) * 3600_000;
417
- if (state.sentMessages?.length) {
417
+ const deleteAfter = Number(config.telegram.deleteAfterHours);
418
+ const maxAge = Number.isFinite(deleteAfter) && deleteAfter > 0
419
+ ? deleteAfter * 3600_000
420
+ : 0;
421
+ if (maxAge > 0 && state.sentMessages?.length) {
418
422
  const now = Date.now();
419
423
  const keep = [];
420
424
  for (const msg of state.sentMessages) {
package/package.json CHANGED
@@ -1,65 +1,69 @@
1
- {
2
- "name": "claude-notification-plugin",
3
- "productName": "claude-notification-plugin",
4
- "version": "1.1.104",
5
- "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
- "type": "module",
7
- "engines": {
8
- "node": ">=18.0.0"
9
- },
10
- "files": [
11
- ".claude-plugin/",
12
- "bin/",
13
- "claude_img/claude.png",
14
- "hooks/",
15
- "listener/",
16
- "notifier/",
17
- "commit-sha",
18
- "README.md",
19
- "LICENSE"
20
- ],
21
- "bin": {
22
- "claude-notify": "bin/cli.js"
23
- },
24
- "scripts": {
25
- "prepack": "git rev-parse HEAD > commit-sha",
26
- "postinstall": "node bin/install.js",
27
- "lint": "eslint .",
28
- "lint:fix": "eslint --fix .",
29
- "listener:restart": "claude-notify listener restart",
30
- "listener:stop": "claude-notify listener stop",
31
- "listener:start": "claude-notify listener start",
32
- "listener:status": "claude-notify listener status"
33
- },
34
- "keywords": [
35
- "claude",
36
- "claude-code",
37
- "notifications",
38
- "telegram",
39
- "hooks",
40
- "macos",
41
- "linux",
42
- "cross-platform"
43
- ],
44
- "author": {
45
- "name": "Viacheslav Makarov",
46
- "email": "npmjs@bazilio.ru"
47
- },
48
- "license": "MIT",
49
- "repository": {
50
- "type": "git",
51
- "url": "git+https://github.com/Bazilio-san/claude-notification-plugin.git"
52
- },
53
- "homepage": "https://github.com/Bazilio-san/claude-notification-plugin#readme",
54
- "publishConfig": {
55
- "access": "public"
56
- },
57
- "dependencies": {
58
- "node-notifier": "^10.0.1",
59
- "node-pty": "^1.1.0"
60
- },
61
- "devDependencies": {
62
- "eslint-plugin-import": "^2.31.0",
63
- "eslint-plugin-unused-imports": "^4.4.1"
64
- }
65
- }
1
+ {
2
+ "name": "claude-notification-plugin",
3
+ "productName": "claude-notification-plugin",
4
+ "version": "1.1.108",
5
+ "description": "Telegram listener daemon + Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=18.0.0"
9
+ },
10
+ "files": [
11
+ ".claude-plugin/",
12
+ "bin/",
13
+ "claude_img/claude.png",
14
+ "hooks/",
15
+ "listener/",
16
+ "notifier/",
17
+ "commit-sha",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "bin": {
22
+ "claude-notify": "bin/cli.js"
23
+ },
24
+ "scripts": {
25
+ "prepack": "git rev-parse HEAD > commit-sha",
26
+ "postinstall": "node bin/install.js",
27
+ "lint": "eslint .",
28
+ "lint:fix": "eslint --fix .",
29
+ "listener:restart": "claude-notify listener restart",
30
+ "listener:stop": "claude-notify listener stop",
31
+ "listener:start": "claude-notify listener start",
32
+ "listener:status": "claude-notify listener status",
33
+ "agents:link": "node scripts/claude-2-agents-symlink.js setup",
34
+ "agents:link:status": "node scripts/claude-2-agents-symlink.js status",
35
+ "agents:link:remove": "node scripts/claude-2-agents-symlink.js remove"
36
+ },
37
+ "keywords": [
38
+ "claude",
39
+ "claude-code",
40
+ "notifications",
41
+ "telegram",
42
+ "hooks",
43
+ "macos",
44
+ "linux",
45
+ "cross-platform"
46
+ ],
47
+ "author": {
48
+ "name": "Viacheslav Makarov",
49
+ "email": "npmjs@bazilio.ru"
50
+ },
51
+ "license": "MIT",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/Bazilio-san/claude-notification-plugin.git"
55
+ },
56
+ "homepage": "https://github.com/Bazilio-san/claude-notification-plugin#readme",
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "dependencies": {
61
+ "@xterm/headless": "^6.0.0",
62
+ "node-notifier": "^10.0.1",
63
+ "node-pty": "^1.1.0"
64
+ },
65
+ "devDependencies": {
66
+ "eslint-plugin-import": "^2.31.0",
67
+ "eslint-plugin-unused-imports": "^4.4.1"
68
+ }
69
+ }