claude-notification-plugin 1.1.91 → 1.1.93
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/commit-sha +1 -1
- package/listener/listener.js +213 -287
- package/listener/telegram-poller.js +44 -39
- package/listener/work-queue.js +0 -22
- 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.93",
|
|
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/commit-sha
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
58be79dcf9e2efe54246c2bb73f8171796e40804
|
package/listener/listener.js
CHANGED
|
@@ -185,6 +185,7 @@ for (const alias of Object.keys(listenerConfig.projects)) {
|
|
|
185
185
|
// catches the case where Claude keeps emitting bytes (update checks, spinners)
|
|
186
186
|
// but never produces a Stop hook signal, so inactivity never accumulates.
|
|
187
187
|
async function runWatchdog () {
|
|
188
|
+
const timeoutMin = Math.round(taskTimeout / 60000);
|
|
188
189
|
for (const [workDir, entry] of Object.entries(queue.queues)) {
|
|
189
190
|
if (!entry.active) {
|
|
190
191
|
continue;
|
|
@@ -194,9 +195,7 @@ async function runWatchdog () {
|
|
|
194
195
|
continue;
|
|
195
196
|
}
|
|
196
197
|
const task = entry.active;
|
|
197
|
-
const label = formatLabel(entry);
|
|
198
198
|
const elapsedMin = Math.round((Date.now() - startedAt) / 60000);
|
|
199
|
-
const timeoutMin = Math.round(taskTimeout / 60000);
|
|
200
199
|
logger.warn(`Watchdog: stale task "${task.id}" in ${workDir} (${elapsedMin}min) — killing PTY`);
|
|
201
200
|
if (runner.isRunning(workDir)) {
|
|
202
201
|
try {
|
|
@@ -205,24 +204,10 @@ async function runWatchdog () {
|
|
|
205
204
|
logger.error(`Watchdog: cancel PTY failed in ${workDir}: ${err.message}`);
|
|
206
205
|
}
|
|
207
206
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
await poller.deleteMessage(task.runningMessageId);
|
|
213
|
-
}
|
|
214
|
-
const header = `⏰ <code>${label}</code>\nTask forcefully stopped — exceeded ${timeoutMin} min wall-clock limit`;
|
|
215
|
-
const sentId = await poller.sendMessage(header, task.telegramMessageId);
|
|
216
|
-
if (!sentId && task.telegramMessageId) {
|
|
217
|
-
await poller.sendMessage(`${header}: ${escapeHtml(task.text)}`);
|
|
218
|
-
}
|
|
219
|
-
} catch (err) {
|
|
220
|
-
logger.error(`Watchdog: notify failed: ${err.message}`);
|
|
221
|
-
}
|
|
222
|
-
const next = queue.onTaskComplete(workDir, 'STALE (watchdog cleanup)');
|
|
223
|
-
if (next) {
|
|
224
|
-
startTask(workDir, next);
|
|
225
|
-
}
|
|
207
|
+
await notifyTaskCompletion(workDir, task, 'timeout', {
|
|
208
|
+
timeoutMin,
|
|
209
|
+
reason: `exceeded ${timeoutMin} min wall-clock limit`,
|
|
210
|
+
});
|
|
226
211
|
}
|
|
227
212
|
}
|
|
228
213
|
|
|
@@ -246,154 +231,135 @@ setInterval(runWatchdog, 60_000);
|
|
|
246
231
|
// TASK RUNNER EVENTS
|
|
247
232
|
// ----------------------
|
|
248
233
|
|
|
249
|
-
runner.
|
|
234
|
+
// Single completion path for runner events. Composes the final message and
|
|
235
|
+
// atomically replaces the running message via editMessage when possible — this
|
|
236
|
+
// avoids the prior delete-then-send race where a failed send would leave the
|
|
237
|
+
// user with no visible reply at all.
|
|
238
|
+
async function notifyTaskCompletion (workDir, task, kind, payload = {}) {
|
|
250
239
|
stopLiveConsole(workDir);
|
|
251
240
|
runner.cleanActivitySignal(workDir);
|
|
252
241
|
const entry = queue.queues[workDir];
|
|
253
|
-
const label = formatLabel(entry);
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
242
|
+
const label = formatLabel(entry?.project, entry?.branch);
|
|
243
|
+
const output = payload.text || '';
|
|
244
|
+
|
|
245
|
+
// Build header
|
|
246
|
+
let header;
|
|
247
|
+
let queueResult;
|
|
248
|
+
if (kind === 'error') {
|
|
249
|
+
header = `❌ <code>${label}</code>\nError`;
|
|
250
|
+
queueResult = `ERROR: ${payload.errorMsg}`;
|
|
251
|
+
} else if (kind === 'timeout') {
|
|
252
|
+
const reason = payload.reason || `no activity for ${payload.timeoutMin} min`;
|
|
253
|
+
header = `⏰ <code>${label}</code>\nTask forcefully stopped — ${reason}`;
|
|
254
|
+
queueResult = 'TIMEOUT';
|
|
255
|
+
} else if (task.raw) {
|
|
256
|
+
// /clear wipes Claude's context — reset our counters to match.
|
|
257
|
+
if ((task.text || '').trim().toLowerCase() === '/clear') {
|
|
265
258
|
sessions.delete(workDir);
|
|
266
259
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
startTask(workDir, next);
|
|
260
|
+
header = `📨 <code>${label}</code> sent <code>${escapeHtml(task.text)}</code>`;
|
|
261
|
+
queueResult = output;
|
|
262
|
+
} else {
|
|
263
|
+
// Update session tracking for non-raw completions
|
|
264
|
+
const session = sessions.get(workDir) || { taskCount: 0 };
|
|
265
|
+
session.taskCount++;
|
|
266
|
+
session.lastSessionId = payload.sessionId || session.lastSessionId;
|
|
267
|
+
if (payload.contextWindow && payload.totalTokens) {
|
|
268
|
+
session.lastContextPct = Math.round((payload.totalTokens / payload.contextWindow) * 100);
|
|
277
269
|
}
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Update session tracking
|
|
282
|
-
const session = sessions.get(workDir) || { taskCount: 0 };
|
|
283
|
-
session.taskCount++;
|
|
284
|
-
session.lastSessionId = result.sessionId || session.lastSessionId;
|
|
285
|
-
if (result.contextWindow && result.totalTokens) {
|
|
286
|
-
session.lastContextPct = Math.round((result.totalTokens / result.contextWindow) * 100);
|
|
287
|
-
}
|
|
288
|
-
sessions.set(workDir, session);
|
|
270
|
+
sessions.set(workDir, session);
|
|
289
271
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
272
|
+
const parts = [];
|
|
273
|
+
if (task.continueSession) {
|
|
274
|
+
parts.push(`#${session.taskCount}`);
|
|
275
|
+
}
|
|
276
|
+
if (payload.durationMs) {
|
|
277
|
+
parts.push(formatDuration(payload.durationMs));
|
|
278
|
+
}
|
|
279
|
+
if (payload.numTurns > 1) {
|
|
280
|
+
parts.push(`${payload.numTurns} turns`);
|
|
281
|
+
}
|
|
282
|
+
if (session.lastContextPct) {
|
|
283
|
+
parts.push(`ctx ${session.lastContextPct}%`);
|
|
284
|
+
}
|
|
285
|
+
if (payload.cost) {
|
|
286
|
+
parts.push(`$${payload.cost.toFixed(2)}`);
|
|
287
|
+
}
|
|
288
|
+
const info = parts.length ? ` (${parts.join(', ')})` : '';
|
|
289
|
+
const icon = task.continueSession ? '🔄' : '🆕';
|
|
290
|
+
header = `✅ ${icon} <code>${label}</code>${info}`;
|
|
291
|
+
queueResult = output;
|
|
306
292
|
}
|
|
307
|
-
const sessionInfo = sessionParts.length > 0 ? ` (${sessionParts.join(', ')})` : '';
|
|
308
|
-
const sessionIcon = task.continueSession ? '🔄' : '🆕';
|
|
309
293
|
|
|
310
|
-
// Build
|
|
311
|
-
const
|
|
312
|
-
const
|
|
294
|
+
// Build body. Long output → head+tail summary in chat plus full output as document.
|
|
295
|
+
const errPayload = kind === 'error' ? payload.errorMsg : '';
|
|
296
|
+
const visible = output || errPayload;
|
|
313
297
|
let body = '';
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
`result_${task.id}.txt`,
|
|
322
|
-
`Full output for: ${task.text.slice(0, 100)}`
|
|
323
|
-
);
|
|
298
|
+
let attachFullOutput = false;
|
|
299
|
+
if (visible) {
|
|
300
|
+
if (visible.length > 20000) {
|
|
301
|
+
const head = visible.slice(0, 2000);
|
|
302
|
+
const tail = visible.slice(-2000);
|
|
303
|
+
body = `\n\n<pre>${escapeHtml(head)}\n\n... (${visible.length} chars) ...\n\n${escapeHtml(tail)}</pre>`;
|
|
304
|
+
attachFullOutput = true;
|
|
324
305
|
} else {
|
|
325
|
-
body = `\n\n<pre>${escapeHtml(
|
|
306
|
+
body = `\n\n<pre>${escapeHtml(visible)}</pre>`;
|
|
326
307
|
}
|
|
327
308
|
}
|
|
328
309
|
|
|
329
|
-
|
|
330
|
-
const sentId = await poller.sendMessage(headerShort + body, task.telegramMessageId);
|
|
331
|
-
if (!sentId && task.telegramMessageId) {
|
|
332
|
-
// Reply failed — original message was deleted, send without reply but with full task text
|
|
333
|
-
await poller.sendMessage(headerFull + body);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Process next in queue
|
|
337
|
-
const next = queue.onTaskComplete(workDir, output);
|
|
338
|
-
if (next) {
|
|
339
|
-
startTask(workDir, next);
|
|
340
|
-
}
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
runner.on('error', async (workDir, task, errorMsg) => {
|
|
344
|
-
stopLiveConsole(workDir);
|
|
345
|
-
runner.cleanActivitySignal(workDir);
|
|
346
|
-
const entry = queue.queues[workDir];
|
|
347
|
-
const label = formatLabel(entry);
|
|
348
|
-
|
|
349
|
-
await poller.deleteMessage(task.runningMessageId);
|
|
310
|
+
const finalText = header + body;
|
|
350
311
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
312
|
+
// Prefer atomic edit of the existing "Running" message — single Telegram entry,
|
|
313
|
+
// no flicker, and if the edit fails we still have the running message visible.
|
|
314
|
+
let edited = false;
|
|
315
|
+
if (task.runningMessageId) {
|
|
316
|
+
edited = await poller.editMessage(task.runningMessageId, finalText);
|
|
355
317
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
318
|
+
if (!edited) {
|
|
319
|
+
// Edit failed (message deleted, or text exceeds 4096 chars and Telegram refused).
|
|
320
|
+
// Send a fresh message; only delete the running one if the send succeeded.
|
|
321
|
+
const sentId = await poller.sendMessage(finalText, task.telegramMessageId);
|
|
322
|
+
if (!sentId && task.telegramMessageId) {
|
|
323
|
+
// Fall back: send without reply (original may have been deleted)
|
|
324
|
+
await poller.sendMessage(finalText);
|
|
325
|
+
}
|
|
326
|
+
if (sentId && task.runningMessageId) {
|
|
327
|
+
await poller.deleteMessage(task.runningMessageId);
|
|
328
|
+
}
|
|
360
329
|
}
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
runner.on('timeout', async (workDir, task) => {
|
|
364
|
-
stopLiveConsole(workDir);
|
|
365
|
-
runner.cleanActivitySignal(workDir);
|
|
366
|
-
const entry = queue.queues[workDir];
|
|
367
|
-
const label = formatLabel(entry);
|
|
368
|
-
const timeoutMin = Math.round(taskTimeout / 60000);
|
|
369
330
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
await poller.sendMessage(headerFull);
|
|
331
|
+
if (attachFullOutput) {
|
|
332
|
+
await poller.sendDocument(
|
|
333
|
+
Buffer.from(visible, 'utf-8'),
|
|
334
|
+
`result_${task.id}.txt`,
|
|
335
|
+
`Full output for: ${task.text.slice(0, 100)}`,
|
|
336
|
+
);
|
|
377
337
|
}
|
|
378
338
|
|
|
379
|
-
const next = queue.onTaskComplete(workDir,
|
|
339
|
+
const next = queue.onTaskComplete(workDir, queueResult);
|
|
380
340
|
if (next) {
|
|
381
341
|
startTask(workDir, next);
|
|
382
342
|
}
|
|
383
|
-
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
runner.on('complete', (workDir, task, result) => notifyTaskCompletion(workDir, task, 'complete', result));
|
|
346
|
+
runner.on('error', (workDir, task, errorMsg) => notifyTaskCompletion(workDir, task, 'error', { errorMsg }));
|
|
347
|
+
runner.on('timeout', (workDir, task) => notifyTaskCompletion(workDir, task, 'timeout', {
|
|
348
|
+
timeoutMin: Math.round(taskTimeout / 60000),
|
|
349
|
+
}));
|
|
384
350
|
|
|
385
351
|
// ----------------------
|
|
386
352
|
// HELPERS
|
|
387
353
|
// ----------------------
|
|
388
354
|
|
|
389
|
-
function formatLabel (
|
|
390
|
-
if (!
|
|
355
|
+
function formatLabel (project, branch) {
|
|
356
|
+
if (!project) {
|
|
391
357
|
return 'unknown';
|
|
392
358
|
}
|
|
393
|
-
if (
|
|
394
|
-
return `&${
|
|
359
|
+
if (branch && branch !== 'main' && branch !== 'master') {
|
|
360
|
+
return `&${project}/${branch}`;
|
|
395
361
|
}
|
|
396
|
-
return `&${
|
|
362
|
+
return `&${project}`;
|
|
397
363
|
}
|
|
398
364
|
|
|
399
365
|
function getClaudeArgs (projectAlias) {
|
|
@@ -460,77 +426,75 @@ function shouldContinueSession (workDir) {
|
|
|
460
426
|
return sessions.has(workDir);
|
|
461
427
|
}
|
|
462
428
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
function _getPtyContent (workDir) {
|
|
490
|
-
const raw = runner.getBuffer(workDir);
|
|
491
|
-
if (!raw) {
|
|
492
|
-
return null;
|
|
429
|
+
// Source priority: JSONL (richer, structured) → PTY buffer fallback.
|
|
430
|
+
// Returns short tail-content (~liveConsoleMaxOutputChars) or null.
|
|
431
|
+
function getLiveContent (workDir) {
|
|
432
|
+
if (liveConsoleSource !== 'pty') {
|
|
433
|
+
let reader = jsonlReaders.get(workDir);
|
|
434
|
+
if (!reader) {
|
|
435
|
+
const sid = runner.getSessionId(workDir);
|
|
436
|
+
const jsonlPath = sid ? resolveJsonlPath(workDir, sid) : resolveJsonlByMtime(workDir);
|
|
437
|
+
if (jsonlPath) {
|
|
438
|
+
reader = new JsonlReader(jsonlPath, logger);
|
|
439
|
+
jsonlReaders.set(workDir, reader);
|
|
440
|
+
logger.info(`JSONL reader initialized: ${jsonlPath}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (reader) {
|
|
444
|
+
reader.readNew();
|
|
445
|
+
const content = reader.getDisplayContent(jsonlMaxContentChars);
|
|
446
|
+
if (content) {
|
|
447
|
+
return content;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (liveConsoleSource === 'jsonl') {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
493
453
|
}
|
|
494
|
-
const cleaned = cleanPtyOutput(
|
|
454
|
+
const cleaned = cleanPtyOutput(runner.getBuffer(workDir) || '');
|
|
495
455
|
if (!cleaned) {
|
|
496
456
|
return null;
|
|
497
457
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
458
|
+
if (cleaned.length <= liveConsoleMaxOutputChars) {
|
|
459
|
+
return cleaned;
|
|
460
|
+
}
|
|
461
|
+
// Trim head to a clean line boundary
|
|
462
|
+
const tail = cleaned.slice(-liveConsoleMaxOutputChars);
|
|
463
|
+
const nl = tail.indexOf('\n');
|
|
464
|
+
return nl >= 0 ? tail.slice(nl + 1) : tail;
|
|
504
465
|
}
|
|
505
466
|
|
|
467
|
+
const LIVE_CONSOLE_MAX_FAILS = 5;
|
|
468
|
+
|
|
506
469
|
function startLiveConsole (workDir, messageId, header) {
|
|
507
470
|
stopLiveConsole(workDir);
|
|
508
471
|
if (!liveConsoleEnabled || !messageId) {
|
|
509
472
|
return;
|
|
510
473
|
}
|
|
511
474
|
let lastSentText = '';
|
|
475
|
+
let consecutiveFails = 0;
|
|
512
476
|
const timer = setInterval(async () => {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
477
|
+
const output = getLiveContent(workDir);
|
|
478
|
+
if (!output || output === lastSentText) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const startedAt = new Date(runner.getActive(workDir)?.startedAt || Date.now()).getTime();
|
|
482
|
+
const elapsed = formatDuration(Date.now() - startedAt);
|
|
483
|
+
const activity = runner.getActivity(workDir);
|
|
484
|
+
const activityLine = activity && (Date.now() - activity.timestamp < 30000)
|
|
485
|
+
? `\n<b>${escapeHtml(formatActivity(activity))}</b>`
|
|
486
|
+
: '';
|
|
487
|
+
const text = `${header}\n<i>${elapsed}</i>${activityLine}\n\n<pre>${escapeHtml(output)}</pre>`;
|
|
488
|
+
const ok = await poller.editMessage(messageId, text);
|
|
489
|
+
if (ok) {
|
|
524
490
|
lastSentText = output;
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
} catch (err) {
|
|
533
|
-
logger.warn(`Live console edit error: ${err.message}`);
|
|
491
|
+
consecutiveFails = 0;
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
consecutiveFails++;
|
|
495
|
+
if (consecutiveFails >= LIVE_CONSOLE_MAX_FAILS) {
|
|
496
|
+
logger.warn(`Live console stopped for ${workDir}: ${consecutiveFails} consecutive edit failures (message likely deleted)`);
|
|
497
|
+
stopLiveConsole(workDir);
|
|
534
498
|
}
|
|
535
499
|
}, liveConsoleIntervalMillis);
|
|
536
500
|
liveConsoleTimers.set(workDir, timer);
|
|
@@ -547,58 +511,42 @@ function stopLiveConsole (workDir) {
|
|
|
547
511
|
|
|
548
512
|
async function startTask (workDir, task) {
|
|
549
513
|
const entry = queue.queues[workDir];
|
|
550
|
-
const label = formatLabel(entry);
|
|
514
|
+
const label = formatLabel(entry?.project, entry?.branch);
|
|
551
515
|
const continueSession = shouldContinueSession(workDir);
|
|
552
516
|
const session = sessions.get(workDir);
|
|
553
517
|
|
|
554
|
-
//
|
|
518
|
+
// Build running header. Raw forwards a slash-command to the live PTY; non-raw
|
|
519
|
+
// is a regular agent task with session info. Both reuse the live console so
|
|
520
|
+
// long-running raw commands (e.g. SuperClaude skills) still show progress.
|
|
521
|
+
let runningHeader;
|
|
522
|
+
let runningFull;
|
|
555
523
|
if (task.raw) {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
if (task.telegramMessageId) {
|
|
559
|
-
runningMsgId = await poller.sendMessage(runningRaw, task.telegramMessageId);
|
|
560
|
-
}
|
|
561
|
-
if (!runningMsgId) {
|
|
562
|
-
runningMsgId = await poller.sendMessage(runningRaw);
|
|
563
|
-
}
|
|
564
|
-
task.runningMessageId = runningMsgId;
|
|
565
|
-
const claudeArgs = applyResumeArgs(getClaudeArgs(entry?.project), workDir);
|
|
566
|
-
try {
|
|
567
|
-
runner.run(workDir, task, claudeArgs, continueSession);
|
|
568
|
-
queue.markStarted(workDir, task.pid || 0);
|
|
569
|
-
} catch (err) {
|
|
570
|
-
logger.error(`Failed to start raw task: ${err.message}`);
|
|
571
|
-
poller.sendMessage(`❌ <code>${label}</code>\nFailed to start: ${escapeHtml(err.message)}`);
|
|
572
|
-
queue.onTaskComplete(workDir, `START_ERROR: ${err.message}`);
|
|
573
|
-
}
|
|
574
|
-
return;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// Build running message with session info
|
|
578
|
-
let sessionTag = '';
|
|
579
|
-
if (continueSession && session) {
|
|
580
|
-
const ctxPart = session.lastContextPct ? `, ctx ${session.lastContextPct}%` : '';
|
|
581
|
-
sessionTag = ` 🔄 #${session.taskCount + 1}${ctxPart}`;
|
|
524
|
+
runningHeader = `📨 <code>${label}</code> sending <code>${escapeHtml(task.text)}</code>…`;
|
|
525
|
+
runningFull = runningHeader;
|
|
582
526
|
} else {
|
|
583
|
-
sessionTag
|
|
527
|
+
let sessionTag;
|
|
528
|
+
if (continueSession && session) {
|
|
529
|
+
const ctxPart = session.lastContextPct ? `, ctx ${session.lastContextPct}%` : '';
|
|
530
|
+
sessionTag = ` 🔄 #${session.taskCount + 1}${ctxPart}`;
|
|
531
|
+
} else {
|
|
532
|
+
sessionTag = ' 🆕';
|
|
533
|
+
}
|
|
534
|
+
runningHeader = `⏳ <code>${label}</code>${sessionTag}\nRunning...`;
|
|
535
|
+
runningFull = `⏳ <code>${label}</code>${sessionTag}\nRunning: ${escapeHtml(task.text)}`;
|
|
584
536
|
}
|
|
585
537
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
let runningMsgId =
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
runningMsgId = await poller.sendMessage(runningShort, task.telegramMessageId);
|
|
593
|
-
if (!runningMsgId) {
|
|
594
|
-
runningMsgId = await poller.sendMessage(runningFull);
|
|
595
|
-
}
|
|
596
|
-
} else {
|
|
538
|
+
// Reply with short header (user message is already visible in the quote).
|
|
539
|
+
// If reply target is gone, fall back to a fresh message that includes task text.
|
|
540
|
+
let runningMsgId = task.telegramMessageId
|
|
541
|
+
? await poller.sendMessage(runningHeader, task.telegramMessageId)
|
|
542
|
+
: null;
|
|
543
|
+
if (!runningMsgId) {
|
|
597
544
|
runningMsgId = await poller.sendMessage(runningFull);
|
|
598
545
|
}
|
|
599
546
|
|
|
600
547
|
task.runningMessageId = runningMsgId;
|
|
601
548
|
startLiveConsole(workDir, runningMsgId, runningFull);
|
|
549
|
+
|
|
602
550
|
const claudeArgs = applyResumeArgs(getClaudeArgs(entry?.project), workDir);
|
|
603
551
|
try {
|
|
604
552
|
runner.run(workDir, task, claudeArgs, continueSession);
|
|
@@ -716,9 +664,7 @@ function handleStatus (args) {
|
|
|
716
664
|
const buttons = [];
|
|
717
665
|
for (const s of statuses) {
|
|
718
666
|
const branchLabel = s.branch || 'main';
|
|
719
|
-
const label =
|
|
720
|
-
? `&${target.project}/${s.branch}`
|
|
721
|
-
: `&${target.project}`;
|
|
667
|
+
const label = formatLabel(target.project, s.branch);
|
|
722
668
|
if (s.active) {
|
|
723
669
|
const elapsed = s.active.startedAt
|
|
724
670
|
? formatDuration(Date.now() - new Date(s.active.startedAt).getTime())
|
|
@@ -759,9 +705,7 @@ function handleStatus (args) {
|
|
|
759
705
|
text += `\n<b>${escapeHtml(project)}</b>:`;
|
|
760
706
|
for (const s of statuses) {
|
|
761
707
|
const branchLabel = s.branch || 'main';
|
|
762
|
-
const label =
|
|
763
|
-
? `&${project}/${s.branch}`
|
|
764
|
-
: `&${project}`;
|
|
708
|
+
const label = formatLabel(project, s.branch);
|
|
765
709
|
if (s.active) {
|
|
766
710
|
const elapsed = s.active.startedAt
|
|
767
711
|
? formatDuration(Date.now() - new Date(s.active.startedAt).getTime())
|
|
@@ -797,21 +741,19 @@ function handleQueue () {
|
|
|
797
741
|
let text = '📋 <b>Queues:</b>\n';
|
|
798
742
|
for (const [project, statuses] of Object.entries(all)) {
|
|
799
743
|
for (const s of statuses) {
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
text += `\n ${i + 1}. ${escapeHtml(entry.queue[i].text)}`;
|
|
814
|
-
}
|
|
744
|
+
if (!s.active && s.queueLength === 0) {
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
const label = formatLabel(project, s.branch);
|
|
748
|
+
text += `\n<b>${escapeHtml(label)}</b>:`;
|
|
749
|
+
if (s.active) {
|
|
750
|
+
text += `\n ▶ ${escapeHtml(s.active.text)}`;
|
|
751
|
+
}
|
|
752
|
+
// s.workDir comes from queue.getProjectStatus / getAllStatus — no n^2 lookup needed.
|
|
753
|
+
const entry = s.workDir ? queue.queues[s.workDir] : null;
|
|
754
|
+
if (entry?.queue) {
|
|
755
|
+
for (let i = 0; i < entry.queue.length; i++) {
|
|
756
|
+
text += `\n ${i + 1}. ${escapeHtml(entry.queue[i].text)}`;
|
|
815
757
|
}
|
|
816
758
|
}
|
|
817
759
|
}
|
|
@@ -831,13 +773,13 @@ async function handleCancel (args) {
|
|
|
831
773
|
return `❌ ${escapeHtml(err.message)}`;
|
|
832
774
|
}
|
|
833
775
|
|
|
776
|
+
const label = formatLabel(projectAlias, branch);
|
|
834
777
|
if (!runner.isRunning(workDir)) {
|
|
835
|
-
return `❌ No active task in
|
|
778
|
+
return `❌ No active task in ${escapeHtml(label)}`;
|
|
836
779
|
}
|
|
837
780
|
|
|
838
781
|
runner.cancel(workDir);
|
|
839
782
|
const next = queue.cancelActive(workDir);
|
|
840
|
-
const label = branch ? `&${projectAlias}/${branch}` : `&${projectAlias}`;
|
|
841
783
|
|
|
842
784
|
if (next) {
|
|
843
785
|
startTask(workDir, next);
|
|
@@ -883,7 +825,7 @@ function handleClear (args) {
|
|
|
883
825
|
}
|
|
884
826
|
|
|
885
827
|
const count = queue.clearQueue(workDir);
|
|
886
|
-
const label =
|
|
828
|
+
const label = formatLabel(projectAlias, branch);
|
|
887
829
|
|
|
888
830
|
// Also reset session
|
|
889
831
|
sessions.delete(workDir);
|
|
@@ -905,7 +847,7 @@ function handleNewSession (args) {
|
|
|
905
847
|
return `❌ ${escapeHtml(err.message)}`;
|
|
906
848
|
}
|
|
907
849
|
|
|
908
|
-
const label =
|
|
850
|
+
const label = formatLabel(projectAlias, branch);
|
|
909
851
|
const session = sessions.get(workDir);
|
|
910
852
|
|
|
911
853
|
sessions.delete(workDir);
|
|
@@ -1263,7 +1205,7 @@ function handlePty (args) {
|
|
|
1263
1205
|
}
|
|
1264
1206
|
const info = runner.getSessionInfo(workDir);
|
|
1265
1207
|
if (!info) {
|
|
1266
|
-
return `🖥 No PTY session for
|
|
1208
|
+
return `🖥 No PTY session for ${escapeHtml(formatLabel(target.project, target.branch))}`;
|
|
1267
1209
|
}
|
|
1268
1210
|
return formatPtyInfo(target.project, target.branch, workDir, info);
|
|
1269
1211
|
}
|
|
@@ -1285,27 +1227,17 @@ function handlePty (args) {
|
|
|
1285
1227
|
}
|
|
1286
1228
|
|
|
1287
1229
|
function formatPtyInfo (project, branch, workDir, info) {
|
|
1288
|
-
const label =
|
|
1289
|
-
? `&${project}/${branch}`
|
|
1290
|
-
: `&${project}`;
|
|
1230
|
+
const label = formatLabel(project, branch);
|
|
1291
1231
|
const elapsed = info.startedAt
|
|
1292
1232
|
? formatDuration(Date.now() - new Date(info.startedAt).getTime())
|
|
1293
1233
|
: '-';
|
|
1294
1234
|
const liveTimer = liveConsoleTimers.has(workDir) ? '✅' : '❌';
|
|
1295
1235
|
const hasJsonl = jsonlReaders.has(workDir) ? '✅' : '❌';
|
|
1296
1236
|
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
lastLines = jsonlContent.split('\n').slice(-15).join('\n');
|
|
1302
|
-
} else {
|
|
1303
|
-
const raw = runner.getBuffer(workDir);
|
|
1304
|
-
const cleaned = raw ? cleanPtyOutput(raw) : '';
|
|
1305
|
-
if (cleaned) {
|
|
1306
|
-
lastLines = cleaned.split('\n').slice(-15).join('\n');
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1237
|
+
const liveContent = getLiveContent(workDir);
|
|
1238
|
+
const lastLines = liveContent
|
|
1239
|
+
? liveContent.split('\n').slice(-15).join('\n')
|
|
1240
|
+
: '(empty)';
|
|
1309
1241
|
|
|
1310
1242
|
return `<b>${escapeHtml(label)}</b>
|
|
1311
1243
|
State: <code>${info.state}</code>
|
|
@@ -1321,7 +1253,7 @@ PTY log: <code>${info.hasLogStream ? 'writing' : 'off'}</code>
|
|
|
1321
1253
|
async function handleSessions (args) {
|
|
1322
1254
|
let target = parseTarget(args);
|
|
1323
1255
|
if (!target) {
|
|
1324
|
-
const def = getDefaultProject(
|
|
1256
|
+
const def = getDefaultProject(listenerConfig.projects);
|
|
1325
1257
|
if (!def) {
|
|
1326
1258
|
return 'Usage: /sessions &project[/branch]';
|
|
1327
1259
|
}
|
|
@@ -1333,9 +1265,7 @@ async function handleSessions (args) {
|
|
|
1333
1265
|
} catch (err) {
|
|
1334
1266
|
return `❌ ${escapeHtml(err.message)}`;
|
|
1335
1267
|
}
|
|
1336
|
-
const labelTarget = target.
|
|
1337
|
-
? `&${target.project}/${target.branch}`
|
|
1338
|
-
: `&${target.project}`;
|
|
1268
|
+
const labelTarget = formatLabel(target.project, target.branch);
|
|
1339
1269
|
|
|
1340
1270
|
const items = await listSessions(workDir, {
|
|
1341
1271
|
limit: sessionsListLimit,
|
|
@@ -1410,9 +1340,7 @@ async function handleResumeSession (args, kill) {
|
|
|
1410
1340
|
} catch (err) {
|
|
1411
1341
|
return `❌ ${escapeHtml(err.message)}`;
|
|
1412
1342
|
}
|
|
1413
|
-
const labelTarget = target.
|
|
1414
|
-
? `&${target.project}/${target.branch}`
|
|
1415
|
-
: `&${target.project}`;
|
|
1343
|
+
const labelTarget = formatLabel(target.project, target.branch);
|
|
1416
1344
|
|
|
1417
1345
|
const dirName = cwdToProjectDir(workDir);
|
|
1418
1346
|
const filePath = path.join(CLAUDE_DIR, 'projects', dirName, `${sessionId}.jsonl`);
|
|
@@ -1463,9 +1391,7 @@ function handleHistory () {
|
|
|
1463
1391
|
}
|
|
1464
1392
|
let text = '📜 <b>Recent tasks:</b>\n';
|
|
1465
1393
|
for (const h of history.reverse()) {
|
|
1466
|
-
const label = h.
|
|
1467
|
-
? `&${h.project}/${h.branch}`
|
|
1468
|
-
: `&${h.project}`;
|
|
1394
|
+
const label = formatLabel(h.project, h.branch);
|
|
1469
1395
|
const status = h.result === 'CANCELLED' ? '🛑' : h.result?.startsWith('ERROR') ? '❌' : '✅';
|
|
1470
1396
|
text += `\n${status} [${escapeHtml(label)}] ${escapeHtml(h.text)}`;
|
|
1471
1397
|
}
|
|
@@ -1598,7 +1524,7 @@ async function handleTask (parsed, telegramMessageId) {
|
|
|
1598
1524
|
startTask(workDir, result.task);
|
|
1599
1525
|
} else {
|
|
1600
1526
|
const entry = queue.queues[workDir];
|
|
1601
|
-
const label = formatLabel(entry);
|
|
1527
|
+
const label = formatLabel(entry?.project, entry?.branch);
|
|
1602
1528
|
await poller.sendMessage(
|
|
1603
1529
|
`📋 [${escapeHtml(label)}] Queued (position ${result.position}).\n`
|
|
1604
1530
|
+ `Currently running: ${escapeHtml(result.activeTask.text)}`,
|
|
@@ -1659,7 +1585,7 @@ async function mainLoop () {
|
|
|
1659
1585
|
}
|
|
1660
1586
|
}
|
|
1661
1587
|
} else if (parsed.type === 'task') {
|
|
1662
|
-
logger.info(`Task for
|
|
1588
|
+
logger.info(`Task for ${formatLabel(parsed.project, parsed.branch)}: ${parsed.text}`);
|
|
1663
1589
|
await handleTask(parsed, msg.messageId);
|
|
1664
1590
|
}
|
|
1665
1591
|
}
|
|
@@ -126,50 +126,55 @@ export class TelegramPoller {
|
|
|
126
126
|
const chunks = splitMessage(text);
|
|
127
127
|
let firstMessageId = null;
|
|
128
128
|
for (const chunk of chunks) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
parse_mode: 'HTML',
|
|
134
|
-
};
|
|
135
|
-
if (replyToMessageId) {
|
|
136
|
-
body.reply_to_message_id = replyToMessageId;
|
|
137
|
-
}
|
|
138
|
-
// Attach inline keyboard only to the last chunk
|
|
139
|
-
if (replyMarkup && chunk === chunks[chunks.length - 1]) {
|
|
140
|
-
body.reply_markup = replyMarkup;
|
|
141
|
-
}
|
|
142
|
-
const res = await fetch(`${this.baseUrl}/sendMessage`, {
|
|
143
|
-
method: 'POST',
|
|
144
|
-
headers: { 'Content-Type': 'application/json' },
|
|
145
|
-
body: JSON.stringify(body),
|
|
146
|
-
});
|
|
147
|
-
const data = await res.json();
|
|
148
|
-
if (!data.ok) {
|
|
149
|
-
// Retry without HTML parse mode
|
|
150
|
-
const res2 = await fetch(`${this.baseUrl}/sendMessage`, {
|
|
151
|
-
method: 'POST',
|
|
152
|
-
headers: { 'Content-Type': 'application/json' },
|
|
153
|
-
body: JSON.stringify({
|
|
154
|
-
chat_id: this.chatId,
|
|
155
|
-
text: chunk,
|
|
156
|
-
...(replyToMessageId ? { reply_to_message_id: replyToMessageId } : {}),
|
|
157
|
-
}),
|
|
158
|
-
});
|
|
159
|
-
const data2 = await res2.json();
|
|
160
|
-
if (data2.ok && !firstMessageId) {
|
|
161
|
-
firstMessageId = data2.result.message_id;
|
|
162
|
-
}
|
|
163
|
-
} else if (!firstMessageId) {
|
|
164
|
-
firstMessageId = data.result.message_id;
|
|
165
|
-
}
|
|
166
|
-
} catch (err) {
|
|
167
|
-
this.logger.error(`sendMessage error: ${err.message}`);
|
|
129
|
+
const isLast = chunk === chunks[chunks.length - 1];
|
|
130
|
+
const id = await this._sendChunk(chunk, replyToMessageId, replyMarkup && isLast);
|
|
131
|
+
if (id && !firstMessageId) {
|
|
132
|
+
firstMessageId = id;
|
|
168
133
|
}
|
|
169
134
|
}
|
|
170
135
|
return firstMessageId;
|
|
171
136
|
}
|
|
172
137
|
|
|
138
|
+
// Send one chunk. Tries HTML first; on failure (e.g. malformed entities), retries
|
|
139
|
+
// as plain text. Returns Telegram messageId on success, null on hard failure.
|
|
140
|
+
async _sendChunk (text, replyToMessageId, replyMarkup) {
|
|
141
|
+
const base = { chat_id: this.chatId, text };
|
|
142
|
+
if (replyToMessageId) {
|
|
143
|
+
base.reply_to_message_id = replyToMessageId;
|
|
144
|
+
}
|
|
145
|
+
if (replyMarkup) {
|
|
146
|
+
base.reply_markup = replyMarkup;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
let res = await fetch(`${this.baseUrl}/sendMessage`, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: { 'Content-Type': 'application/json' },
|
|
152
|
+
body: JSON.stringify({ ...base, parse_mode: 'HTML' }),
|
|
153
|
+
});
|
|
154
|
+
let data = await res.json();
|
|
155
|
+
if (data.ok) {
|
|
156
|
+
return data.result.message_id;
|
|
157
|
+
}
|
|
158
|
+
const htmlErr = data.description || `error_code ${data.error_code}`;
|
|
159
|
+
// Retry without HTML parse mode (covers entity-parsing errors)
|
|
160
|
+
res = await fetch(`${this.baseUrl}/sendMessage`, {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: { 'Content-Type': 'application/json' },
|
|
163
|
+
body: JSON.stringify(base),
|
|
164
|
+
});
|
|
165
|
+
data = await res.json();
|
|
166
|
+
if (data.ok) {
|
|
167
|
+
this.logger.warn(`sendMessage: HTML failed (${htmlErr}), plain succeeded`);
|
|
168
|
+
return data.result.message_id;
|
|
169
|
+
}
|
|
170
|
+
this.logger.error(`sendMessage failed: HTML=${htmlErr}, plain=${data.description || data.error_code}`);
|
|
171
|
+
return null;
|
|
172
|
+
} catch (err) {
|
|
173
|
+
this.logger.error(`sendMessage error: ${err.message}`);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
173
178
|
async answerCallbackQuery (callbackQueryId, text) {
|
|
174
179
|
try {
|
|
175
180
|
const body = { callback_query_id: callbackQueryId };
|
package/listener/work-queue.js
CHANGED
|
@@ -229,28 +229,6 @@ export class WorkQueue {
|
|
|
229
229
|
return this._loadHistory().slice(-limit);
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
/**
|
|
233
|
-
* Watchdog: clean up stale active tasks (dead PIDs, expired timeouts).
|
|
234
|
-
*/
|
|
235
|
-
watchdog (taskTimeout) {
|
|
236
|
-
const now = Date.now();
|
|
237
|
-
const recovered = [];
|
|
238
|
-
for (const [workDir, entry] of Object.entries(this.queues)) {
|
|
239
|
-
if (!entry.active) {
|
|
240
|
-
continue;
|
|
241
|
-
}
|
|
242
|
-
const startedAt = entry.active.startedAt ? new Date(entry.active.startedAt).getTime() : 0;
|
|
243
|
-
const isStale = startedAt > 0 && (now - startedAt) > taskTimeout;
|
|
244
|
-
|
|
245
|
-
if (isStale) {
|
|
246
|
-
this.logger.warn(`Watchdog: stale task "${entry.active.id}" in ${workDir}`);
|
|
247
|
-
const next = this.onTaskComplete(workDir, 'STALE (watchdog cleanup)');
|
|
248
|
-
recovered.push({ workDir, next });
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
return recovered;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
232
|
_countTotal () {
|
|
255
233
|
let count = 0;
|
|
256
234
|
for (const entry of Object.values(this.queues)) {
|
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.93",
|
|
5
5
|
"description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"engines": {
|