claude-notification-plugin 1.1.92 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-notification-plugin",
3
- "version": "1.1.92",
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
- 063fbd2e2dc8b76a90f1736d30d8c032c453bbca
1
+ 58be79dcf9e2efe54246c2bb73f8171796e40804
@@ -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
- stopLiveConsole(workDir);
209
- runner.cleanActivitySignal(workDir);
210
- try {
211
- if (task.runningMessageId) {
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.on('complete', async (workDir, task, result) => {
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
- // Delete the "Running" message
256
- await poller.deleteMessage(task.runningMessageId);
257
-
258
- const output = result.text || '';
259
-
260
- if (task.raw) {
261
- // Raw slash-command: compact "sent" confirmation, don't bump session counter.
262
- // `/clear` wipes Claude's context reset our counters too.
263
- const normalized = (task.text || '').trim().toLowerCase();
264
- if (normalized === '/clear') {
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
- const headerShort = `📨 <code>${label}</code> sent <code>${escapeHtml(task.text)}</code>`;
268
- const tail = output ? output.slice(-1500) : '';
269
- const body = tail ? `\n\n<pre>${escapeHtml(tail)}</pre>` : '';
270
- const sentId = await poller.sendMessage(headerShort + body, task.telegramMessageId);
271
- if (!sentId && task.telegramMessageId) {
272
- await poller.sendMessage(headerShort + body);
273
- }
274
- const next = queue.onTaskComplete(workDir, output);
275
- if (next) {
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
- return;
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
- // Build session info line
291
- const sessionParts = [];
292
- if (task.continueSession) {
293
- sessionParts.push(`#${session.taskCount}`);
294
- }
295
- if (result.durationMs) {
296
- sessionParts.push(formatDuration(result.durationMs));
297
- }
298
- if (result.numTurns > 1) {
299
- sessionParts.push(`${result.numTurns} turns`);
300
- }
301
- if (session.lastContextPct) {
302
- sessionParts.push(`ctx ${session.lastContextPct}%`);
303
- }
304
- if (result.cost) {
305
- sessionParts.push(`$${result.cost.toFixed(2)}`);
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 result
311
- const headerShort = `✅ ${sessionIcon} <code>${label}</code>${sessionInfo}`;
312
- const headerFull = `${headerShort}\n\n${escapeHtml(task.text)}`;
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
- if (output) {
315
- if (output.length > 20000) {
316
- const head = output.slice(0, 2000);
317
- const tail = output.slice(-2000);
318
- body = `\n\n<pre>${escapeHtml(head)}\n\n... (${output.length} chars) ...\n\n${escapeHtml(tail)}</pre>`;
319
- await poller.sendDocument(
320
- Buffer.from(output, 'utf-8'),
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(output)}</pre>`;
306
+ body = `\n\n<pre>${escapeHtml(visible)}</pre>`;
326
307
  }
327
308
  }
328
309
 
329
- // Try reply to original message (short header, task text visible in quote)
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
- const body = `\n\n<pre>${escapeHtml(errorMsg)}</pre>`;
352
- const sentId = await poller.sendMessage(`❌ <code>${label}</code>\nError:${body}`, task.telegramMessageId);
353
- if (!sentId && task.telegramMessageId) {
354
- await poller.sendMessage(`❌ <code>${label}</code>\nError: ${escapeHtml(task.text)}${body}`);
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
- const next = queue.onTaskComplete(workDir, `ERROR: ${errorMsg}`);
358
- if (next) {
359
- startTask(workDir, next);
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
- await poller.deleteMessage(task.runningMessageId);
371
-
372
- const headerShort = `⏰ <code>${label}</code>\nTask forcefully stopped — no activity for ${timeoutMin} min`;
373
- const headerFull = `${headerShort}: ${escapeHtml(task.text)}`;
374
- const sentId = await poller.sendMessage(headerShort, task.telegramMessageId);
375
- if (!sentId && task.telegramMessageId) {
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, 'TIMEOUT');
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 (entry) {
390
- if (!entry) {
355
+ function formatLabel (project, branch) {
356
+ if (!project) {
391
357
  return 'unknown';
392
358
  }
393
- if (entry.branch && entry.branch !== 'main' && entry.branch !== 'master') {
394
- return `&${entry.project}/${entry.branch}`;
359
+ if (branch && branch !== 'main' && branch !== 'master') {
360
+ return `&${project}/${branch}`;
395
361
  }
396
- return `&${entry.project}`;
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
- function _initJsonlReader (workDir) {
464
- const sessionId = runner.getSessionId(workDir);
465
- const jsonlPath = sessionId
466
- ? resolveJsonlPath(workDir, sessionId)
467
- : resolveJsonlByMtime(workDir);
468
- if (jsonlPath) {
469
- const reader = new JsonlReader(jsonlPath, logger);
470
- jsonlReaders.set(workDir, reader);
471
- logger.info(`JSONL reader initialized: ${jsonlPath}`);
472
- return reader;
473
- }
474
- return null;
475
- }
476
-
477
- function _getJsonlContent (workDir) {
478
- let reader = jsonlReaders.get(workDir);
479
- if (!reader) {
480
- reader = _initJsonlReader(workDir);
481
- }
482
- if (!reader) {
483
- return null;
484
- }
485
- reader.readNew();
486
- return reader.getDisplayContent(jsonlMaxContentChars);
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(raw);
454
+ const cleaned = cleanPtyOutput(runner.getBuffer(workDir) || '');
495
455
  if (!cleaned) {
496
456
  return null;
497
457
  }
498
- const tail = cleaned.length > liveConsoleMaxOutputChars
499
- ? cleaned.slice(-liveConsoleMaxOutputChars)
500
- : cleaned;
501
- return cleaned.length > liveConsoleMaxOutputChars
502
- ? tail.slice(tail.indexOf('\n') + 1)
503
- : tail;
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
- try {
514
- let output = null;
515
- if (liveConsoleSource === 'jsonl' || liveConsoleSource === 'auto') {
516
- output = _getJsonlContent(workDir);
517
- }
518
- if (!output && (liveConsoleSource === 'pty' || liveConsoleSource === 'auto')) {
519
- output = _getPtyContent(workDir);
520
- }
521
- if (!output || output === lastSentText) {
522
- return;
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
- const elapsed = formatDuration(Date.now() - new Date(runner.getActive(workDir)?.startedAt || Date.now()).getTime());
526
- const activity = runner.getActivity(workDir);
527
- const activityLine = activity && (Date.now() - activity.timestamp < 30000)
528
- ? `\n<b>${escapeHtml(formatActivity(activity))}</b>`
529
- : '';
530
- const text = `${header}\n<i>${elapsed}</i>${activityLine}\n\n<pre>${escapeHtml(output)}</pre>`;
531
- await poller.editMessage(messageId, text);
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
- // Raw slash-commands get a compact running message and skip the live console.
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
- const runningRaw = `📨 <code>${label}</code> sending <code>${escapeHtml(task.text)}</code>…`;
557
- let runningMsgId = null;
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
- const runningShort = `⏳ <code>${label}</code>${sessionTag}\nRunning...`;
587
- const runningFull = `⏳ <code>${label}</code>${sessionTag}\nRunning: ${escapeHtml(task.text)}`;
588
- let runningMsgId = null;
589
-
590
- if (task.telegramMessageId) {
591
- // In replies, the quoted user message already contains task text.
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 = s.branch && s.branch !== 'main' && s.branch !== 'master'
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 = s.branch && s.branch !== 'main' && s.branch !== 'master'
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
- const label = s.branch && s.branch !== 'main' && s.branch !== 'master'
801
- ? `&${project}/${s.branch}`
802
- : `&${project}`;
803
- if (s.active || s.queueLength > 0) {
804
- text += `\n<b>${escapeHtml(label)}</b>:`;
805
- if (s.active) {
806
- text += `\n ▶ ${escapeHtml(s.active.text)}`;
807
- }
808
- const entry = queue.queues[Object.keys(queue.queues).find(
809
- (wd) => queue.queues[wd].project === project && queue.queues[wd].branch === s.branch
810
- )];
811
- if (entry?.queue) {
812
- for (let i = 0; i < entry.queue.length; i++) {
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 &${escapeHtml(projectAlias)}${branch ? '/' + escapeHtml(branch) : ''}`;
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 = branch ? `&${projectAlias}/${branch}` : `&${projectAlias}`;
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 = branch ? `&${projectAlias}/${branch}` : `&${projectAlias}`;
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 &${escapeHtml(target.project)}${target.branch ? '/' + escapeHtml(target.branch) : ''}`;
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 = branch && branch !== 'main' && branch !== 'master'
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
- // Prefer JSONL content if available, fall back to PTY buffer
1298
- let lastLines = '(empty)';
1299
- const jsonlContent = _getJsonlContent(workDir);
1300
- if (jsonlContent) {
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>
@@ -1333,9 +1265,7 @@ async function handleSessions (args) {
1333
1265
  } catch (err) {
1334
1266
  return `❌ ${escapeHtml(err.message)}`;
1335
1267
  }
1336
- const labelTarget = target.branch && target.branch !== 'main' && target.branch !== 'master'
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.branch && target.branch !== 'main' && target.branch !== 'master'
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.branch && h.branch !== 'main' && h.branch !== 'master'
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 &${parsed.project}${parsed.branch ? '/' + parsed.branch : ''}: ${parsed.text}`);
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
- try {
130
- const body = {
131
- chat_id: this.chatId,
132
- text: chunk,
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 };
@@ -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.92",
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": {