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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/listener-cli.js +28 -12
- package/commit-sha +1 -1
- package/listener/telegram-poller.js +39 -5
- package/listener/work-queue.js +33 -11
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-notification-plugin",
|
|
3
|
-
"version": "1.1.
|
|
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",
|
package/bin/listener-cli.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
280
|
-
|
|
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
|
|
318
|
+
remaining = remaining.slice(splitAt).replace(/^\n+/, '');
|
|
285
319
|
}
|
|
286
320
|
return chunks;
|
|
287
321
|
}
|
package/listener/work-queue.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|