claude-notification-plugin 1.1.83 → 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/README.md +21 -0
- package/bin/listener-cli.js +28 -12
- package/commit-sha +1 -1
- package/listener/listener.js +52 -1
- package/listener/message-parser.js +29 -3
- package/listener/pty-runner.js +32 -2
- package/listener/telegram-poller.js +39 -5
- package/listener/work-queue.js +35 -12
- 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/README.md
CHANGED
|
@@ -265,6 +265,27 @@ Projects are referenced with the `&` prefix (e.g. `&api`, `&api/branch`).
|
|
|
265
265
|
| `/menu` | Show help with inline buttons |
|
|
266
266
|
| `/help` | Show help with inline buttons |
|
|
267
267
|
|
|
268
|
+
#### Raw REPL commands (`%cmd`)
|
|
269
|
+
|
|
270
|
+
To forward a slash-command straight into the live Claude Code REPL (instead of
|
|
271
|
+
letting the listener intercept it), prefix it with `%`:
|
|
272
|
+
|
|
273
|
+
| Telegram message | What gets sent to Claude PTY |
|
|
274
|
+
|-------------------------|----------------------------------------------|
|
|
275
|
+
| `%clear` | `/clear` in the default project |
|
|
276
|
+
| `&api %compact` | `/compact` in the `&api` main worktree |
|
|
277
|
+
| `&api/feature %cost` | `/cost` in the `&api/feature` worktree |
|
|
278
|
+
| `%%foo` | literal task starting with `%foo` (escape) |
|
|
279
|
+
|
|
280
|
+
Useful for `/clear`, `/compact`, `/cost`, `/model`, `/status` etc. — commands
|
|
281
|
+
that act on the running Claude session without going through the listener's
|
|
282
|
+
command router. The PTY session is **kept alive**, so subsequent tasks
|
|
283
|
+
continue in the same conversation (with cleared context after `/clear`).
|
|
284
|
+
|
|
285
|
+
Because REPL commands usually don't produce a `Stop` hook event, raw tasks
|
|
286
|
+
complete after ~8 s of PTY inactivity; the last chunk of output is included
|
|
287
|
+
in the confirmation reply.
|
|
288
|
+
|
|
268
289
|
#### Registering projects on the fly (`/addproject` + `/seen`)
|
|
269
290
|
|
|
270
291
|
Whenever the notifier fires for a folder, it records the absolute path,
|
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
|
package/listener/listener.js
CHANGED
|
@@ -201,6 +201,29 @@ runner.on('complete', async (workDir, task, result) => {
|
|
|
201
201
|
// Delete the "Running" message
|
|
202
202
|
await poller.deleteMessage(task.runningMessageId);
|
|
203
203
|
|
|
204
|
+
const output = result.text || '';
|
|
205
|
+
|
|
206
|
+
if (task.raw) {
|
|
207
|
+
// Raw slash-command: compact "sent" confirmation, don't bump session counter.
|
|
208
|
+
// `/clear` wipes Claude's context — reset our counters too.
|
|
209
|
+
const normalized = (task.text || '').trim().toLowerCase();
|
|
210
|
+
if (normalized === '/clear') {
|
|
211
|
+
sessions.delete(workDir);
|
|
212
|
+
}
|
|
213
|
+
const headerShort = `📨 <code>${label}</code> sent <code>${escapeHtml(task.text)}</code>`;
|
|
214
|
+
const tail = output ? output.slice(-1500) : '';
|
|
215
|
+
const body = tail ? `\n\n<pre>${escapeHtml(tail)}</pre>` : '';
|
|
216
|
+
const sentId = await poller.sendMessage(headerShort + body, task.telegramMessageId);
|
|
217
|
+
if (!sentId && task.telegramMessageId) {
|
|
218
|
+
await poller.sendMessage(headerShort + body);
|
|
219
|
+
}
|
|
220
|
+
const next = queue.onTaskComplete(workDir, output);
|
|
221
|
+
if (next) {
|
|
222
|
+
startTask(workDir, next);
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
204
227
|
// Update session tracking
|
|
205
228
|
const session = sessions.get(workDir) || { taskCount: 0 };
|
|
206
229
|
session.taskCount++;
|
|
@@ -231,7 +254,6 @@ runner.on('complete', async (workDir, task, result) => {
|
|
|
231
254
|
const sessionIcon = task.continueSession ? '🔄' : '🆕';
|
|
232
255
|
|
|
233
256
|
// Build result
|
|
234
|
-
const output = result.text || '';
|
|
235
257
|
const headerShort = `✅ ${sessionIcon} <code>${label}</code>${sessionInfo}`;
|
|
236
258
|
const headerFull = `${headerShort}\n\n${escapeHtml(task.text)}`;
|
|
237
259
|
let body = '';
|
|
@@ -429,6 +451,29 @@ async function startTask (workDir, task) {
|
|
|
429
451
|
const continueSession = shouldContinueSession(workDir);
|
|
430
452
|
const session = sessions.get(workDir);
|
|
431
453
|
|
|
454
|
+
// Raw slash-commands get a compact running message and skip the live console.
|
|
455
|
+
if (task.raw) {
|
|
456
|
+
const runningRaw = `📨 <code>${label}</code> sending <code>${escapeHtml(task.text)}</code>…`;
|
|
457
|
+
let runningMsgId = null;
|
|
458
|
+
if (task.telegramMessageId) {
|
|
459
|
+
runningMsgId = await poller.sendMessage(runningRaw, task.telegramMessageId);
|
|
460
|
+
}
|
|
461
|
+
if (!runningMsgId) {
|
|
462
|
+
runningMsgId = await poller.sendMessage(runningRaw);
|
|
463
|
+
}
|
|
464
|
+
task.runningMessageId = runningMsgId;
|
|
465
|
+
const claudeArgs = getClaudeArgs(entry?.project);
|
|
466
|
+
try {
|
|
467
|
+
runner.run(workDir, task, claudeArgs, continueSession);
|
|
468
|
+
queue.markStarted(workDir, task.pid || 0);
|
|
469
|
+
} catch (err) {
|
|
470
|
+
logger.error(`Failed to start raw task: ${err.message}`);
|
|
471
|
+
poller.sendMessage(`❌ <code>${label}</code>\nFailed to start: ${escapeHtml(err.message)}`);
|
|
472
|
+
queue.onTaskComplete(workDir, `START_ERROR: ${err.message}`);
|
|
473
|
+
}
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
432
477
|
// Build running message with session info
|
|
433
478
|
let sessionTag = '';
|
|
434
479
|
if (continueSession && session) {
|
|
@@ -1239,6 +1284,11 @@ function handleHelp () {
|
|
|
1239
1284
|
<code>&project/branch task</code> — worktree
|
|
1240
1285
|
<code>task</code> — default project
|
|
1241
1286
|
|
|
1287
|
+
<b>Raw REPL commands (forward to live Claude session):</b>
|
|
1288
|
+
<code>%clear</code> — send <code>/clear</code> into the running Claude PTY
|
|
1289
|
+
<code>&project %compact</code> — same, targeting project
|
|
1290
|
+
<code>%%foo</code> — literal task starting with <code>%foo</code> (escape)
|
|
1291
|
+
|
|
1242
1292
|
<b>Session:</b>
|
|
1243
1293
|
🆕 = new session, 🔄 = continuing session
|
|
1244
1294
|
ctx N% = context window usage`,
|
|
@@ -1286,6 +1336,7 @@ async function handleTask (parsed, telegramMessageId) {
|
|
|
1286
1336
|
parsed.branch || 'main',
|
|
1287
1337
|
parsed.text,
|
|
1288
1338
|
telegramMessageId,
|
|
1339
|
+
!!parsed.raw,
|
|
1289
1340
|
);
|
|
1290
1341
|
|
|
1291
1342
|
if (result.error) {
|
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
* &project text → { type: 'task', project, branch: null, text }
|
|
10
10
|
* text → { type: 'task', project: <defaultProject>, branch: null, text }
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Raw slash-commands forwarded to the live Claude REPL:
|
|
13
|
+
* %cmd [args] → task with raw:true, text='/cmd [args]'
|
|
14
|
+
* &project %cmd [args] → same, targeting the project's PTY
|
|
15
|
+
* %%foo → literal task starting with "%foo" (escape)
|
|
16
|
+
*
|
|
17
|
+
* Any /word is treated as a listener command (known or unknown).
|
|
13
18
|
* Project designation uses & prefix: &project or &project/branch.
|
|
14
19
|
*
|
|
15
20
|
* @param {string} text - The message text.
|
|
@@ -41,7 +46,8 @@ export function parseMessage (text, defaultProject) {
|
|
|
41
46
|
const projectMatch = trimmed.match(/^&(\S+)\s+([\s\S]+)$/);
|
|
42
47
|
if (projectMatch) {
|
|
43
48
|
const target = projectMatch[1];
|
|
44
|
-
const
|
|
49
|
+
const rawText = projectMatch[2].trim();
|
|
50
|
+
const { text: taskText, raw } = parseRawPrefix(rawText);
|
|
45
51
|
|
|
46
52
|
const slashIndex = target.indexOf('/');
|
|
47
53
|
if (slashIndex > 0) {
|
|
@@ -50,6 +56,7 @@ export function parseMessage (text, defaultProject) {
|
|
|
50
56
|
project: target.substring(0, slashIndex),
|
|
51
57
|
branch: target.substring(slashIndex + 1),
|
|
52
58
|
text: taskText,
|
|
59
|
+
raw,
|
|
53
60
|
};
|
|
54
61
|
}
|
|
55
62
|
return {
|
|
@@ -57,19 +64,38 @@ export function parseMessage (text, defaultProject) {
|
|
|
57
64
|
project: target,
|
|
58
65
|
branch: null,
|
|
59
66
|
text: taskText,
|
|
67
|
+
raw,
|
|
60
68
|
};
|
|
61
69
|
}
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
// Plain text → default project
|
|
73
|
+
const { text: taskText, raw } = parseRawPrefix(trimmed);
|
|
65
74
|
return {
|
|
66
75
|
type: 'task',
|
|
67
76
|
project: defaultProject || 'default',
|
|
68
77
|
branch: null,
|
|
69
|
-
text:
|
|
78
|
+
text: taskText,
|
|
79
|
+
raw,
|
|
70
80
|
};
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Detect `%cmd` / `%%literal` prefix.
|
|
85
|
+
* - `%%foo` → plain task "%foo"
|
|
86
|
+
* - `%foo` → raw task "/foo" (forwarded to PTY verbatim)
|
|
87
|
+
* - anything else → unchanged plain task
|
|
88
|
+
*/
|
|
89
|
+
function parseRawPrefix (text) {
|
|
90
|
+
if (text.startsWith('%%')) {
|
|
91
|
+
return { text: text.slice(1), raw: false };
|
|
92
|
+
}
|
|
93
|
+
if (text.startsWith('%')) {
|
|
94
|
+
return { text: '/' + text.slice(1), raw: true };
|
|
95
|
+
}
|
|
96
|
+
return { text, raw: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
73
99
|
/**
|
|
74
100
|
* Parse &project or &project/branch from command args.
|
|
75
101
|
* Returns { project, branch, rest } or null.
|
package/listener/pty-runner.js
CHANGED
|
@@ -4,8 +4,12 @@ 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
8
|
|
|
8
9
|
const DEFAULT_TIMEOUT = 600_000; // 10 minutes
|
|
10
|
+
// Built-in slash-commands (forwarded via %cmd) rarely emit a Stop hook event,
|
|
11
|
+
// so we fall back to "done" after this much buffer inactivity.
|
|
12
|
+
const RAW_INACTIVITY_MS = 8_000;
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
15
|
* PTY-based runner for Claude Code.
|
|
@@ -318,8 +322,11 @@ export class PtyRunner extends EventEmitter {
|
|
|
318
322
|
session._buffer = '';
|
|
319
323
|
this._openPtyLog(session, task);
|
|
320
324
|
|
|
321
|
-
// Set up marker wait + inactivity timeout
|
|
322
|
-
|
|
325
|
+
// Set up marker wait + inactivity timeout. Raw slash-commands use a much
|
|
326
|
+
// shorter window because they typically don't trigger an agent turn and
|
|
327
|
+
// therefore never produce a Stop signal.
|
|
328
|
+
const inactivityMs = task.raw ? RAW_INACTIVITY_MS : this.timeout;
|
|
329
|
+
const markerPromise = this._waitForMarker(pendingId, inactivityMs, session);
|
|
323
330
|
|
|
324
331
|
// Send the task text to the PTY.
|
|
325
332
|
// Bracketed paste mode (\x1b[200~...\x1b[201~) causes Claude to hang in ConPTY,
|
|
@@ -379,6 +386,29 @@ export class PtyRunner extends EventEmitter {
|
|
|
379
386
|
session.currentTask = null;
|
|
380
387
|
|
|
381
388
|
if (err.message === 'Marker timeout') {
|
|
389
|
+
if (task.raw) {
|
|
390
|
+
// Slash commands (e.g. /clear, /cost) usually don't emit a Stop hook.
|
|
391
|
+
// Treat inactivity as successful completion and keep the PTY alive.
|
|
392
|
+
const cleaned = cleanPtyOutput(session._buffer || '').trim();
|
|
393
|
+
const tail = cleaned.length > 2000 ? cleaned.slice(-2000) : cleaned;
|
|
394
|
+
const result = {
|
|
395
|
+
text: tail,
|
|
396
|
+
sessionId: session.sessionId || null,
|
|
397
|
+
cost: 0,
|
|
398
|
+
numTurns: 0,
|
|
399
|
+
durationMs: 0,
|
|
400
|
+
contextWindow: 0,
|
|
401
|
+
totalTokens: 0,
|
|
402
|
+
isError: false,
|
|
403
|
+
raw: true,
|
|
404
|
+
};
|
|
405
|
+
this.logger.info(`PTY raw command finished (no Stop signal) in ${workDir}`);
|
|
406
|
+
if (this.taskLogger) {
|
|
407
|
+
this.taskLogger.logAnswer(task.project || 'unknown', task.branch || 'main', tail, 0);
|
|
408
|
+
}
|
|
409
|
+
this.emit('complete', workDir, task, result);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
382
412
|
this.logger.warn(`PTY task timed out in ${workDir}`);
|
|
383
413
|
this._destroyPty(workDir);
|
|
384
414
|
this.emit('timeout', workDir, task);
|
|
@@ -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
|
@@ -30,7 +30,7 @@ export class WorkQueue {
|
|
|
30
30
|
* Enqueue a task for a workDir. Returns the task object.
|
|
31
31
|
* If no active task, marks it as ready to run immediately.
|
|
32
32
|
*/
|
|
33
|
-
enqueue (workDir, project, branch, text, telegramMessageId) {
|
|
33
|
+
enqueue (workDir, project, branch, text, telegramMessageId, raw = false) {
|
|
34
34
|
if (!this.queues[workDir]) {
|
|
35
35
|
this.queues[workDir] = {
|
|
36
36
|
project,
|
|
@@ -57,6 +57,7 @@ export class WorkQueue {
|
|
|
57
57
|
project,
|
|
58
58
|
branch: branch || 'main',
|
|
59
59
|
telegramMessageId,
|
|
60
|
+
raw: !!raw,
|
|
60
61
|
addedAt: new Date().toISOString(),
|
|
61
62
|
};
|
|
62
63
|
|
|
@@ -262,12 +263,21 @@ export class WorkQueue {
|
|
|
262
263
|
}
|
|
263
264
|
|
|
264
265
|
_load () {
|
|
266
|
+
if (!fs.existsSync(QUEUE_FILE)) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
265
269
|
try {
|
|
266
|
-
|
|
267
|
-
this.queues = JSON.parse(fs.readFileSync(QUEUE_FILE, 'utf-8'));
|
|
268
|
-
}
|
|
270
|
+
this.queues = JSON.parse(fs.readFileSync(QUEUE_FILE, 'utf-8'));
|
|
269
271
|
} catch (err) {
|
|
270
|
-
|
|
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
|
+
}
|
|
271
281
|
this.queues = {};
|
|
272
282
|
}
|
|
273
283
|
}
|
|
@@ -276,7 +286,11 @@ export class WorkQueue {
|
|
|
276
286
|
try {
|
|
277
287
|
const dir = path.dirname(QUEUE_FILE);
|
|
278
288
|
fs.mkdirSync(dir, { recursive: true });
|
|
279
|
-
|
|
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);
|
|
280
294
|
} catch (err) {
|
|
281
295
|
this.logger.error(`Failed to save queue file: ${err.message}`);
|
|
282
296
|
}
|
|
@@ -289,20 +303,29 @@ export class WorkQueue {
|
|
|
289
303
|
history.shift();
|
|
290
304
|
}
|
|
291
305
|
try {
|
|
292
|
-
|
|
306
|
+
const tmp = `${HISTORY_FILE}.${process.pid}.tmp`;
|
|
307
|
+
fs.writeFileSync(tmp, JSON.stringify(history, null, 2));
|
|
308
|
+
fs.renameSync(tmp, HISTORY_FILE);
|
|
293
309
|
} catch {
|
|
294
310
|
// ignore
|
|
295
311
|
}
|
|
296
312
|
}
|
|
297
313
|
|
|
298
314
|
_loadHistory () {
|
|
315
|
+
if (!fs.existsSync(HISTORY_FILE)) {
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
299
318
|
try {
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
302
327
|
}
|
|
303
|
-
|
|
304
|
-
// ignore
|
|
328
|
+
return [];
|
|
305
329
|
}
|
|
306
|
-
return [];
|
|
307
330
|
}
|
|
308
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": {
|