claude-notification-plugin 1.1.84 → 1.1.86

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.84",
3
+ "version": "1.1.86",
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",
@@ -31,6 +31,7 @@ function getLogFile () {
31
31
  return path.join(CLAUDE_DIR, LISTENER_LOG_FILENAME);
32
32
  }
33
33
  }
34
+
34
35
  const LISTENER_SCRIPT = path.join(__dirname, '..', 'listener', 'listener.js');
35
36
 
36
37
  const command = process.argv[2];
@@ -69,15 +70,20 @@ Commands:
69
70
  }
70
71
 
71
72
  async function startDaemon () {
72
- // Check if already running
73
+ // Replace any prior instance — both stale PID-files and live processes.
74
+ // Telegram allows exactly one getUpdates consumer per token, so a zombie
75
+ // listener (terminal closed, taskkill failed mid-stop, etc.) holds the
76
+ // long-poll slot and blocks the new one with 409 Conflict. Active SIGTERM
77
+ // makes `start` idempotent: user typed `start` → listener is running.
73
78
  const existingPid = readPid();
74
- if (existingPid && isProcessAlive(existingPid)) {
75
- console.log(`Listener is already running (PID: ${existingPid})`);
76
- process.exit(1);
77
- }
78
-
79
- // Clean stale PID file
80
79
  if (existingPid) {
80
+ if (isProcessAlive(existingPid)) {
81
+ console.log(`Replacing prior listener (PID: ${existingPid})...`);
82
+ if (!terminatePid(existingPid)) {
83
+ console.error(`Failed to stop prior listener (PID: ${existingPid}). Exiting.`);
84
+ process.exit(1);
85
+ }
86
+ }
81
87
  try {
82
88
  fs.unlinkSync(PID_PATH);
83
89
  } catch {
@@ -167,7 +173,20 @@ function stopDaemon () {
167
173
  }
168
174
 
169
175
  console.log(`Stopping listener (PID: ${pid})...`);
176
+ if (terminatePid(pid)) {
177
+ cleanPid();
178
+ console.log('Listener stopped');
179
+ } else {
180
+ console.error(`Failed to stop listener (PID: ${pid})`);
181
+ }
182
+ }
170
183
 
184
+ /**
185
+ * Best-effort process termination. Returns true if the target is gone after
186
+ * the call, false otherwise. Used both by `stop` and by `start` (to evict a
187
+ * stale prior instance holding the Telegram long-poll slot).
188
+ */
189
+ function terminatePid (pid) {
171
190
  try {
172
191
  if (process.platform === 'win32') {
173
192
  execSync(`taskkill /PID ${pid} /T /F`, {
@@ -176,7 +195,6 @@ function stopDaemon () {
176
195
  });
177
196
  } else {
178
197
  process.kill(pid, 'SIGTERM');
179
- // Wait for graceful shutdown
180
198
  let tries = 10;
181
199
  while (tries-- > 0 && isProcessAlive(pid)) {
182
200
  execSync('sleep 0.5', { stdio: 'ignore' });
@@ -186,11 +204,9 @@ function stopDaemon () {
186
204
  }
187
205
  }
188
206
  } catch {
189
- // Process may already be dead
207
+ // Already dead, or no permission — fall through to the alive check.
190
208
  }
191
-
192
- cleanPid();
193
- console.log('Listener stopped');
209
+ return !isProcessAlive(pid);
194
210
  }
195
211
 
196
212
  async function showStatus () {
package/commit-sha CHANGED
@@ -1 +1 @@
1
- 642d44de740e6e85d391634789966980a389d893
1
+ 84cbba88a79b9dd591d9a047174b93d28e04caa6
@@ -2,6 +2,10 @@
2
2
 
3
3
  const POLL_TIMEOUT = 30; // seconds
4
4
  const MAX_MESSAGE_LENGTH = 4096;
5
+ // Telegram allows exactly one getUpdates consumer per token. If we keep seeing
6
+ // 409 it means another instance is polling — exit so the user notices instead
7
+ // of looping silently.
8
+ const MAX_CONSECUTIVE_409 = 8;
5
9
 
6
10
  export class TelegramPoller {
7
11
  constructor (token, chatId, logger) {
@@ -12,6 +16,7 @@ export class TelegramPoller {
12
16
  this.offset = 0;
13
17
  this._errorBackoff = 0; // current backoff in ms (0 = no backoff)
14
18
  this._consecutiveErrors = 0;
19
+ this._consecutive409 = 0;
15
20
  }
16
21
 
17
22
  async flush () {
@@ -38,7 +43,23 @@ export class TelegramPoller {
38
43
  const res = await fetch(url, { signal: AbortSignal.timeout((POLL_TIMEOUT + 10) * 1000) });
39
44
  const data = await res.json();
40
45
  if (!data.ok) {
41
- this.logger.error(`getUpdates failed: ${JSON.stringify(data)}`);
46
+ if (data.error_code === 409) {
47
+ this._consecutive409++;
48
+ if (this._consecutive409 >= MAX_CONSECUTIVE_409) {
49
+ this.logger.error(
50
+ `409 Conflict persists after ${this._consecutive409} attempts — `
51
+ + 'another listener is holding the bot token. Exiting.',
52
+ );
53
+ console.error(
54
+ `409 Conflict persists after ${this._consecutive409} attempts — `
55
+ + 'another listener (or stray getUpdates consumer) is holding the bot token. Exiting.',
56
+ );
57
+ process.exit(2);
58
+ }
59
+ this.logger.warn(`getUpdates 409 Conflict (attempt ${this._consecutive409}/${MAX_CONSECUTIVE_409})`);
60
+ } else {
61
+ this.logger.error(`getUpdates failed: ${JSON.stringify(data)}`);
62
+ }
42
63
  this._applyBackoff();
43
64
  return [];
44
65
  }
@@ -47,6 +68,7 @@ export class TelegramPoller {
47
68
  this.logger.info('getUpdates recovered after errors');
48
69
  }
49
70
  this._consecutiveErrors = 0;
71
+ this._consecutive409 = 0;
50
72
  this._errorBackoff = 0;
51
73
 
52
74
  const messages = [];
@@ -268,7 +290,9 @@ function splitMessage (text) {
268
290
  return [`${head}\n\n<i>... (truncated ${text.length} chars) ...</i>\n\n${tail}`];
269
291
  }
270
292
 
271
- // Split into chunks preserving line boundaries
293
+ // Split into chunks with tiered preference: paragraph → line → space → hard.
294
+ // Each tier is accepted only if it falls past the midpoint, otherwise
295
+ // we'd produce a tiny chunk followed by a huge one.
272
296
  const chunks = [];
273
297
  let remaining = text;
274
298
  while (remaining.length > 0) {
@@ -276,12 +300,22 @@ function splitMessage (text) {
276
300
  chunks.push(remaining);
277
301
  break;
278
302
  }
279
- let splitAt = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
280
- if (splitAt <= 0) {
303
+ const para = remaining.lastIndexOf('\n\n', MAX_MESSAGE_LENGTH);
304
+ const line = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
305
+ const space = remaining.lastIndexOf(' ', MAX_MESSAGE_LENGTH);
306
+ const half = MAX_MESSAGE_LENGTH / 2;
307
+ let splitAt;
308
+ if (para > half) {
309
+ splitAt = para;
310
+ } else if (line > half) {
311
+ splitAt = line;
312
+ } else if (space > 0) {
313
+ splitAt = space;
314
+ } else {
281
315
  splitAt = MAX_MESSAGE_LENGTH;
282
316
  }
283
317
  chunks.push(remaining.slice(0, splitAt));
284
- remaining = remaining.slice(splitAt + 1);
318
+ remaining = remaining.slice(splitAt).replace(/^\n+/, '');
285
319
  }
286
320
  return chunks;
287
321
  }
@@ -263,12 +263,21 @@ export class WorkQueue {
263
263
  }
264
264
 
265
265
  _load () {
266
+ if (!fs.existsSync(QUEUE_FILE)) {
267
+ return;
268
+ }
266
269
  try {
267
- if (fs.existsSync(QUEUE_FILE)) {
268
- this.queues = JSON.parse(fs.readFileSync(QUEUE_FILE, 'utf-8'));
269
- }
270
+ this.queues = JSON.parse(fs.readFileSync(QUEUE_FILE, 'utf-8'));
270
271
  } catch (err) {
271
- this.logger.error(`Failed to load queue file: ${err.message}`);
272
+ // Move aside corrupt file (truncated by crash mid-write, ENOSPC, etc.)
273
+ // so the next start succeeds instead of looping on a parse error.
274
+ const corrupt = `${QUEUE_FILE}.corrupt-${Date.now()}`;
275
+ try {
276
+ fs.renameSync(QUEUE_FILE, corrupt);
277
+ this.logger.error(`Queue file corrupt, moved to ${corrupt}: ${err.message}`);
278
+ } catch (renameErr) {
279
+ this.logger.error(`Queue file corrupt and rename failed: ${err.message} / ${renameErr.message}`);
280
+ }
272
281
  this.queues = {};
273
282
  }
274
283
  }
@@ -277,7 +286,11 @@ export class WorkQueue {
277
286
  try {
278
287
  const dir = path.dirname(QUEUE_FILE);
279
288
  fs.mkdirSync(dir, { recursive: true });
280
- fs.writeFileSync(QUEUE_FILE, JSON.stringify(this.queues, null, 2));
289
+ // Atomic write: tmp + rename. Crash mid-write leaves the tmp orphan,
290
+ // not a half-written QUEUE_FILE.
291
+ const tmp = `${QUEUE_FILE}.${process.pid}.tmp`;
292
+ fs.writeFileSync(tmp, JSON.stringify(this.queues, null, 2));
293
+ fs.renameSync(tmp, QUEUE_FILE);
281
294
  } catch (err) {
282
295
  this.logger.error(`Failed to save queue file: ${err.message}`);
283
296
  }
@@ -290,20 +303,29 @@ export class WorkQueue {
290
303
  history.shift();
291
304
  }
292
305
  try {
293
- fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
306
+ const tmp = `${HISTORY_FILE}.${process.pid}.tmp`;
307
+ fs.writeFileSync(tmp, JSON.stringify(history, null, 2));
308
+ fs.renameSync(tmp, HISTORY_FILE);
294
309
  } catch {
295
310
  // ignore
296
311
  }
297
312
  }
298
313
 
299
314
  _loadHistory () {
315
+ if (!fs.existsSync(HISTORY_FILE)) {
316
+ return [];
317
+ }
300
318
  try {
301
- if (fs.existsSync(HISTORY_FILE)) {
302
- return JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'));
319
+ return JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'));
320
+ } catch (err) {
321
+ const corrupt = `${HISTORY_FILE}.corrupt-${Date.now()}`;
322
+ try {
323
+ fs.renameSync(HISTORY_FILE, corrupt);
324
+ this.logger.error(`History file corrupt, moved to ${corrupt}: ${err.message}`);
325
+ } catch {
326
+ // ignore
303
327
  }
304
- } catch {
305
- // ignore
328
+ return [];
306
329
  }
307
- return [];
308
330
  }
309
331
  }
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.84",
4
+ "version": "1.1.86",
5
5
  "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
6
  "type": "module",
7
7
  "engines": {