claude-notification-plugin 1.1.38 → 1.1.40

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.38",
3
+ "version": "1.1.40",
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
@@ -248,9 +248,11 @@ All commands start with `/` and execute instantly (not queued).
248
248
  | `/worktrees /project` | List worktrees |
249
249
  | `/worktree /project/branch` | Create a worktree |
250
250
  | `/rmworktree /project/branch` | Remove a worktree |
251
+ | `/pty [/project[/branch]]` | PTY session diagnostics (state, buffer, output) |
251
252
  | `/history` | Recent task history |
252
253
  | `/stop` | Stop the listener |
253
- | `/help` | Show help |
254
+ | `/menu` | Show inline button menu |
255
+ | `/help` | Show help with inline buttons |
254
256
 
255
257
  ### Listener configuration
256
258
 
@@ -269,6 +271,7 @@ All commands start with `/` and execute instantly (not queued).
269
271
  | `liveConsole` | `true` | Stream PTY output to the "Running..." Telegram message in real-time |
270
272
  | `liveConsoleInterval`| `5` | Live console update interval in seconds |
271
273
 
274
+
272
275
  ### Projects and worktrees
273
276
 
274
277
  **The queue is tied to the working directory, not the project name:**
package/commit-sha CHANGED
@@ -1 +1 @@
1
- 0fdb0002b24921b5b1c581fcd13720f2bd1247e0
1
+ 03d819f371c1cd76acd7127b9c1e1a6a90b4daa9
@@ -20,6 +20,7 @@ and executes them on your machine via an interactive Claude Code PTY session. Th
20
20
  - [Projects and worktrees](#projects-and-worktrees)
21
21
  - [Task queues](#task-queues)
22
22
  - [Bot commands](#bot-commands)
23
+ - [Live console and PTY diagnostics](#live-console-and-pty-diagnostics)
23
24
  - [Task lifecycle](#task-lifecycle)
24
25
  - [State files](#state-files)
25
26
  - [Security](#security)
@@ -722,12 +723,75 @@ Bot: 👋 Listener is shutting down...
722
723
 
723
724
  All active tasks will be terminated. Queues are saved to disk and will be restored on the next startup.
724
725
 
726
+ ### /pty — PTY diagnostics
727
+
728
+ Shows real-time information about PTY sessions: state, buffer size, live console status, and the last 15 lines of cleaned output.
729
+
730
+ ```
731
+ You: /pty
732
+ Bot: 🖥 PTY Sessions:
733
+
734
+ /api
735
+ State: busy
736
+ Buffer: 12480 bytes
737
+ Elapsed: 2m 35s
738
+ Live console: ✅
739
+ PTY log: writing
740
+
741
+ ◐ Reading src/auth.js
742
+ ● Editing src/middleware.js
743
+ Added JWT validation...
744
+ ```
745
+
746
+ ```
747
+ You: /pty /api
748
+ Bot: (same, but for a specific project)
749
+ ```
750
+
725
751
  ### /help — help
726
752
 
727
753
  Shows a brief reference for all commands.
728
754
 
729
755
  ---
730
756
 
757
+ ## Live console and PTY diagnostics
758
+
759
+ ### Live console
760
+
761
+ When **`liveConsole`** is enabled (default: `true`), the "⏳ Running..." message in Telegram is periodically updated with the cleaned tail of Claude Code's PTY output, so you can see what Claude is doing in real-time.
762
+
763
+ The output is cleaned from ANSI escape codes and Claude Code UI chrome (logo, status bar, prompts), leaving only meaningful content.
764
+
765
+ Configuration:
766
+ - `liveConsole` — enable/disable (default: `true`)
767
+ - `liveConsoleInterval` — update interval in seconds (default: `5`)
768
+
769
+ ### PTY logs
770
+
771
+ Each running task writes raw PTY output to a file: `{taskLogDir}/{project}_{branch}_pty.log`.
772
+ The file is overwritten when a new task starts for the same project/branch.
773
+
774
+ Monitor in real-time:
775
+ ```bash
776
+ # Linux / macOS / Git Bash
777
+ tail -f ~/.claude/myproject_main_pty.log
778
+
779
+ # Windows PowerShell
780
+ Get-Content ~/.claude/myproject_main_pty.log -Wait -Tail 50
781
+ ```
782
+
783
+ ### /pty command
784
+
785
+ Send `/pty` or `/pty /project` in Telegram to get instant diagnostics:
786
+ - Session state (`busy` / `idle` / `starting`)
787
+ - Buffer size in bytes
788
+ - Elapsed time since task start
789
+ - Whether live console interval is active
790
+ - Whether PTY log stream is writing
791
+ - Last 15 lines of cleaned output
792
+
793
+ ---
794
+
731
795
  ## Task lifecycle
732
796
 
733
797
  ### Path of a task from message to result
@@ -6,7 +6,7 @@ import path from 'path';
6
6
  import process from 'process';
7
7
  import { createLogger } from './logger.js';
8
8
  import { createTaskLogger } from './task-logger.js';
9
- import { TelegramPoller, escapeHtml, stripAnsi } from './telegram-poller.js';
9
+ import { TelegramPoller, escapeHtml, cleanPtyOutput } from './telegram-poller.js';
10
10
  import { WorkQueue } from './work-queue.js';
11
11
  import { PtyRunner } from './pty-runner.js';
12
12
  import { WorktreeManager } from './worktree-manager.js';
@@ -100,7 +100,7 @@ const taskLogDir = config.listener?.taskLogDir || listenerLogDir;
100
100
  fs.mkdirSync(taskLogDir, { recursive: true });
101
101
  const taskLogger = createTaskLogger(taskLogDir);
102
102
 
103
- const runner = new PtyRunner(logger, taskTimeout, taskLogger);
103
+ const runner = new PtyRunner(logger, taskTimeout, taskLogger, taskLogDir);
104
104
 
105
105
  const worktreeManager = new WorktreeManager(config, logger);
106
106
 
@@ -300,11 +300,7 @@ function startLiveConsole (workDir, messageId, header) {
300
300
  if (!raw) {
301
301
  return;
302
302
  }
303
- const cleaned = stripAnsi(raw)
304
- .split('\n')
305
- .map((l) => l.trimEnd())
306
- .filter((l) => l.length > 0)
307
- .join('\n');
303
+ const cleaned = cleanPtyOutput(raw);
308
304
  if (!cleaned) {
309
305
  return;
310
306
  }
@@ -320,10 +316,11 @@ function startLiveConsole (workDir, messageId, header) {
320
316
  return;
321
317
  }
322
318
  lastSentText = output;
323
- const text = `${header}\n\n<pre>${escapeHtml(output)}</pre>`;
319
+ const elapsed = formatDuration(Date.now() - new Date(runner.getActive(workDir)?.startedAt || Date.now()).getTime());
320
+ const text = `${header}\n<i>${elapsed}</i>\n\n<pre>${escapeHtml(output)}</pre>`;
324
321
  await poller.editMessage(messageId, text);
325
- } catch {
326
- // ignore edit errors message may have been deleted
322
+ } catch (err) {
323
+ logger.warn(`Live console edit error: ${err.message}`);
327
324
  }
328
325
  }, liveConsoleInterval);
329
326
  liveConsoleTimers.set(workDir, timer);
@@ -415,10 +412,14 @@ async function handleCommand (cmd, args) {
415
412
  return handleRemoveWorktree(args);
416
413
  case '/history':
417
414
  return handleHistory();
415
+ case '/pty':
416
+ return handlePty(args);
418
417
  case '/stop':
419
418
  return handleStop();
420
419
  case '/help':
421
420
  return handleHelp();
421
+ case '/menu':
422
+ return handleMenu();
422
423
  default:
423
424
  return `Unknown command: ${cmd}`;
424
425
  }
@@ -433,8 +434,12 @@ function handleStatus (args) {
433
434
  return `📊 Project "${target.project}": no active queues`;
434
435
  }
435
436
  let text = `📊 Project "<b>${escapeHtml(target.project)}</b>":\n`;
437
+ const buttons = [];
436
438
  for (const s of statuses) {
437
439
  const branchLabel = s.branch || 'main';
440
+ const label = s.branch && s.branch !== 'main' && s.branch !== 'master'
441
+ ? `/${target.project}/${s.branch}`
442
+ : `/${target.project}`;
438
443
  if (s.active) {
439
444
  const elapsed = s.active.startedAt
440
445
  ? formatDuration(Date.now() - new Date(s.active.startedAt).getTime())
@@ -442,10 +447,20 @@ function handleStatus (args) {
442
447
  text += `\n<b>${escapeHtml(branchLabel)}</b>:\n`;
443
448
  text += ` ▶ ${escapeHtml(s.active.text)} (${elapsed})\n`;
444
449
  text += ` Queue: ${s.queueLength} tasks\n`;
450
+ buttons.push([
451
+ { text: `🛑 Cancel ${label}`, callback_data: `/cancel ${label}` },
452
+ { text: `🧹 Clear ${label}`, callback_data: `/clear ${label}` },
453
+ ]);
445
454
  } else {
446
455
  text += `\n<b>${escapeHtml(branchLabel)}</b>: ✅ idle\n`;
447
456
  text += ` Queue: ${s.queueLength} tasks\n`;
448
457
  }
458
+ buttons.push([
459
+ { text: `🆕 New session ${label}`, callback_data: `/newsession ${label}` },
460
+ ]);
461
+ }
462
+ if (buttons.length > 0) {
463
+ return { text, replyMarkup: { inline_keyboard: buttons } };
449
464
  }
450
465
  return text;
451
466
  }
@@ -460,10 +475,14 @@ function handleStatus (args) {
460
475
  let text = '📊 <b>Status:</b>\n';
461
476
  const uptime = formatDuration(Date.now() - startTime);
462
477
  text += `Uptime: ${uptime}\n`;
478
+ const buttons = [];
463
479
  for (const [project, statuses] of Object.entries(all)) {
464
480
  text += `\n<b>${escapeHtml(project)}</b>:`;
465
481
  for (const s of statuses) {
466
482
  const branchLabel = s.branch || 'main';
483
+ const label = s.branch && s.branch !== 'main' && s.branch !== 'master'
484
+ ? `/${project}/${s.branch}`
485
+ : `/${project}`;
467
486
  if (s.active) {
468
487
  const elapsed = s.active.startedAt
469
488
  ? formatDuration(Date.now() - new Date(s.active.startedAt).getTime())
@@ -472,11 +491,21 @@ function handleStatus (args) {
472
491
  if (s.queueLength > 0) {
473
492
  text += ` +${s.queueLength} queued`;
474
493
  }
494
+ buttons.push([
495
+ { text: `🛑 Cancel ${label}`, callback_data: `/cancel ${label}` },
496
+ { text: `🧹 Clear ${label}`, callback_data: `/clear ${label}` },
497
+ ]);
475
498
  } else {
476
499
  text += `\n ${escapeHtml(branchLabel)}: ✅ idle`;
477
500
  }
501
+ buttons.push([
502
+ { text: `🆕 New session ${label}`, callback_data: `/newsession ${label}` },
503
+ ]);
478
504
  }
479
505
  }
506
+ if (buttons.length > 0) {
507
+ return { text, replyMarkup: { inline_keyboard: buttons } };
508
+ }
480
509
  return text;
481
510
  }
482
511
 
@@ -691,6 +720,63 @@ function handleRemoveWorktree (args) {
691
720
  }
692
721
  }
693
722
 
723
+ function handlePty (args) {
724
+ const target = parseTarget(args);
725
+
726
+ if (target) {
727
+ let workDir;
728
+ try {
729
+ workDir = worktreeManager.resolveWorkDir(target.project, target.branch);
730
+ } catch (err) {
731
+ return `❌ ${escapeHtml(err.message)}`;
732
+ }
733
+ const info = runner.getSessionInfo(workDir);
734
+ if (!info) {
735
+ return `🖥 No PTY session for /${escapeHtml(target.project)}${target.branch ? '/' + escapeHtml(target.branch) : ''}`;
736
+ }
737
+ return formatPtyInfo(target.project, target.branch, workDir, info);
738
+ }
739
+
740
+ // All sessions
741
+ const allInfo = runner.getAllSessionInfo();
742
+ if (Object.keys(allInfo).length === 0) {
743
+ return '🖥 No active PTY sessions';
744
+ }
745
+
746
+ let text = '🖥 <b>PTY Sessions:</b>\n';
747
+ for (const [workDir, info] of Object.entries(allInfo)) {
748
+ const entry = queue.queues[workDir];
749
+ const project = entry?.project || '?';
750
+ const branch = entry?.branch || null;
751
+ text += '\n' + formatPtyInfo(project, branch, workDir, info);
752
+ }
753
+ return text;
754
+ }
755
+
756
+ function formatPtyInfo (project, branch, workDir, info) {
757
+ const label = branch && branch !== 'main' && branch !== 'master'
758
+ ? `/${project}/${branch}`
759
+ : `/${project}`;
760
+ const elapsed = info.startedAt
761
+ ? formatDuration(Date.now() - new Date(info.startedAt).getTime())
762
+ : '-';
763
+ const liveTimer = liveConsoleTimers.has(workDir) ? '✅' : '❌';
764
+ const raw = runner.getBuffer(workDir);
765
+ const cleaned = raw ? cleanPtyOutput(raw) : '';
766
+ const lastLines = cleaned
767
+ ? cleaned.split('\n').slice(-15).join('\n')
768
+ : '(empty)';
769
+
770
+ return `<b>${escapeHtml(label)}</b>
771
+ State: <code>${info.state}</code>
772
+ Buffer: <code>${info.bufferSize}</code> bytes
773
+ Elapsed: ${elapsed}
774
+ Live console: ${liveTimer}
775
+ PTY log: <code>${info.hasLogStream ? 'writing' : 'off'}</code>
776
+
777
+ <pre>${escapeHtml(lastLines)}</pre>`;
778
+ }
779
+
694
780
  function handleHistory () {
695
781
  const history = queue.getHistory(10);
696
782
  if (history.length === 0) {
@@ -715,8 +801,28 @@ async function handleStop () {
715
801
  return null; // Message already sent
716
802
  }
717
803
 
804
+ const MENU_KEYBOARD = {
805
+ inline_keyboard: [
806
+ [
807
+ { text: '📊 Status', callback_data: '/status' },
808
+ { text: '📋 Queue', callback_data: '/queue' },
809
+ { text: '📂 Projects', callback_data: '/projects' },
810
+ ],
811
+ [
812
+ { text: '📜 History', callback_data: '/history' },
813
+ { text: '🖥 PTY', callback_data: '/pty' },
814
+ { text: '📖 Help', callback_data: '/help' },
815
+ ],
816
+ ],
817
+ };
818
+
819
+ function handleMenu () {
820
+ return { text: '📖 <b>Menu:</b>', replyMarkup: MENU_KEYBOARD };
821
+ }
822
+
718
823
  function handleHelp () {
719
- return `<b>📖 Commands:</b>
824
+ return {
825
+ text: `<b>📖 Commands:</b>
720
826
 
721
827
  /status — status of all projects
722
828
  /status /project — project status
@@ -729,8 +835,10 @@ function handleHelp () {
729
835
  /worktrees /project — project worktrees
730
836
  /worktree /project/branch — create worktree
731
837
  /rmworktree /project/branch — remove worktree
838
+ /pty [/project[/branch]] — PTY session diagnostics
732
839
  /history — task history
733
840
  /stop — stop listener
841
+ /menu — command buttons
734
842
  /help — this help
735
843
 
736
844
  <b>Tasks:</b>
@@ -740,7 +848,9 @@ function handleHelp () {
740
848
 
741
849
  <b>Session:</b>
742
850
  🆕 = new session, 🔄 = continuing session
743
- ctx N% = context window usage`;
851
+ ctx N% = context window usage`,
852
+ replyMarkup: MENU_KEYBOARD,
853
+ };
744
854
  }
745
855
 
746
856
  // ----------------------
@@ -834,6 +944,11 @@ async function mainLoop () {
834
944
  try {
835
945
  const messages = await poller.getUpdates();
836
946
  for (const msg of messages) {
947
+ // Answer callback query (Telegram requires this)
948
+ if (msg.callbackQueryId) {
949
+ await poller.answerCallbackQuery(msg.callbackQueryId);
950
+ }
951
+
837
952
  const parsed = parseMessage(msg.text);
838
953
  if (!parsed) {
839
954
  continue;
@@ -843,7 +958,11 @@ async function mainLoop () {
843
958
  logger.info(`Command: ${parsed.cmd} ${parsed.args}`);
844
959
  const response = await handleCommand(parsed.cmd, parsed.args);
845
960
  if (response) {
846
- await poller.sendMessage(response, msg.messageId);
961
+ if (typeof response === 'object' && response.text) {
962
+ await poller.sendMessage(response.text, msg.callbackQueryId ? null : msg.messageId, response.replyMarkup);
963
+ } else {
964
+ await poller.sendMessage(response, msg.callbackQueryId ? null : msg.messageId);
965
+ }
847
966
  }
848
967
  } else if (parsed.type === 'task') {
849
968
  logger.info(`Task for /${parsed.project}${parsed.branch ? '/' + parsed.branch : ''}: ${parsed.text}`);
@@ -13,12 +13,13 @@ const DEFAULT_TIMEOUT = 600_000; // 10 minutes
13
13
  * receives completion signals via marker files written by the notifier hook.
14
14
  */
15
15
  export class PtyRunner extends EventEmitter {
16
- constructor (logger, timeout, taskLogger) {
16
+ constructor (logger, timeout, taskLogger, ptyLogDir) {
17
17
  super();
18
18
  this.logger = logger;
19
19
  this.timeout = timeout || DEFAULT_TIMEOUT;
20
20
  this.taskLogger = taskLogger || null;
21
- // workDir -> { pty, state, currentTask, sessionId, workDir, _pendingId, _buffer }
21
+ this.ptyLogDir = ptyLogDir || null;
22
+ // workDir -> { pty, state, currentTask, sessionId, workDir, _pendingId, _buffer, _logStream }
22
23
  this.sessions = new Map();
23
24
  this.pendingMarkers = new Map(); // pendingId -> resolve callback
24
25
  this._pty = null; // lazy-loaded node-pty module
@@ -196,6 +197,27 @@ export class PtyRunner extends EventEmitter {
196
197
  });
197
198
  }
198
199
 
200
+ _openPtyLog (session, task) {
201
+ if (session._logStream) {
202
+ session._logStream.end();
203
+ session._logStream = null;
204
+ }
205
+ if (!this.ptyLogDir) {
206
+ return;
207
+ }
208
+ try {
209
+ const project = task.project || 'unknown';
210
+ const branch = (task.branch || 'main').replace(/[/\\:*?"<>|]/g, '_');
211
+ const logFile = path.join(this.ptyLogDir, `${project}_${branch}_pty.log`);
212
+ session._logStream = fs.createWriteStream(logFile, { flags: 'w' });
213
+ session._logStream.on('error', () => {
214
+ session._logStream = null;
215
+ });
216
+ } catch {
217
+ // ignore — logging is best-effort
218
+ }
219
+ }
220
+
199
221
  /**
200
222
  * Send a task to an existing PTY session and wait for completion.
201
223
  */
@@ -205,6 +227,8 @@ export class PtyRunner extends EventEmitter {
205
227
  session.state = 'busy';
206
228
  session.currentTask = task;
207
229
  session._pendingId = pendingId;
230
+ session._buffer = '';
231
+ this._openPtyLog(session, task);
208
232
 
209
233
  // Set up marker wait + timeout
210
234
  const markerPromise = this._waitForMarker(pendingId, this.timeout);
@@ -304,6 +328,9 @@ export class PtyRunner extends EventEmitter {
304
328
  if (session._buffer.length > 50000) {
305
329
  session._buffer = session._buffer.slice(-25000);
306
330
  }
331
+ if (session._logStream) {
332
+ session._logStream.write(data);
333
+ }
307
334
  });
308
335
 
309
336
  ptyProcess.onExit(({ exitCode }) => {
@@ -382,6 +409,11 @@ export class PtyRunner extends EventEmitter {
382
409
  this.pendingMarkers.delete(session._pendingId);
383
410
  }
384
411
 
412
+ if (session._logStream) {
413
+ session._logStream.end();
414
+ session._logStream = null;
415
+ }
416
+
385
417
  try {
386
418
  if (session.pty) {
387
419
  session.pty.kill();
@@ -440,6 +472,42 @@ export class PtyRunner extends EventEmitter {
440
472
  return session?._buffer || '';
441
473
  }
442
474
 
475
+ /**
476
+ * Get diagnostic info about a PTY session.
477
+ */
478
+ getSessionInfo (workDir) {
479
+ const session = this.sessions.get(workDir);
480
+ if (!session) {
481
+ return null;
482
+ }
483
+ return {
484
+ state: session.state,
485
+ bufferSize: session._buffer?.length || 0,
486
+ hasLogStream: !!session._logStream,
487
+ taskText: session.currentTask?.text || null,
488
+ startedAt: session.currentTask?.startedAt || null,
489
+ sessionId: session.sessionId || null,
490
+ pendingId: session._pendingId || null,
491
+ };
492
+ }
493
+
494
+ /**
495
+ * Get diagnostic info for all sessions.
496
+ */
497
+ getAllSessionInfo () {
498
+ const result = {};
499
+ for (const [workDir, session] of this.sessions) {
500
+ result[workDir] = {
501
+ state: session.state,
502
+ bufferSize: session._buffer?.length || 0,
503
+ hasLogStream: !!session._logStream,
504
+ taskText: session.currentTask?.text || null,
505
+ startedAt: session.currentTask?.startedAt || null,
506
+ };
507
+ }
508
+ return result;
509
+ }
510
+
443
511
  /**
444
512
  * Cancel all active tasks (for graceful shutdown).
445
513
  */
@@ -16,7 +16,7 @@ export class TelegramPoller {
16
16
 
17
17
  async flush () {
18
18
  try {
19
- const url = `${this.baseUrl}/getUpdates?offset=-1&timeout=0&allowed_updates=["message"]`;
19
+ const url = `${this.baseUrl}/getUpdates?offset=-1&timeout=0&allowed_updates=${encodeURIComponent('["message","callback_query"]')}`;
20
20
  const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
21
21
  const data = await res.json();
22
22
  if (data.ok && data.result?.length) {
@@ -34,7 +34,7 @@ export class TelegramPoller {
34
34
  }
35
35
 
36
36
  try {
37
- const url = `${this.baseUrl}/getUpdates?offset=${this.offset}&timeout=${POLL_TIMEOUT}&allowed_updates=["message"]`;
37
+ const url = `${this.baseUrl}/getUpdates?offset=${this.offset}&timeout=${POLL_TIMEOUT}&allowed_updates=${encodeURIComponent('["message","callback_query"]')}`;
38
38
  const res = await fetch(url, { signal: AbortSignal.timeout((POLL_TIMEOUT + 10) * 1000) });
39
39
  const data = await res.json();
40
40
  if (!data.ok) {
@@ -52,6 +52,23 @@ export class TelegramPoller {
52
52
  const messages = [];
53
53
  for (const update of data.result || []) {
54
54
  this.offset = update.update_id + 1;
55
+
56
+ // Handle callback_query (inline button press)
57
+ const cb = update.callback_query;
58
+ if (cb) {
59
+ if (String(cb.message?.chat?.id) !== this.chatId) {
60
+ continue;
61
+ }
62
+ messages.push({
63
+ messageId: cb.message.message_id,
64
+ text: cb.data,
65
+ chatId: cb.message.chat.id,
66
+ date: cb.message.date,
67
+ callbackQueryId: cb.id,
68
+ });
69
+ continue;
70
+ }
71
+
55
72
  const msg = update.message;
56
73
  if (!msg || !msg.text) {
57
74
  continue;
@@ -83,7 +100,7 @@ export class TelegramPoller {
83
100
  this._errorBackoff = Math.min(1000 * Math.pow(2, this._consecutiveErrors - 1), 30000);
84
101
  }
85
102
 
86
- async sendMessage (text, replyToMessageId) {
103
+ async sendMessage (text, replyToMessageId, replyMarkup) {
87
104
  const chunks = splitMessage(text);
88
105
  let firstMessageId = null;
89
106
  for (const chunk of chunks) {
@@ -96,6 +113,10 @@ export class TelegramPoller {
96
113
  if (replyToMessageId) {
97
114
  body.reply_to_message_id = replyToMessageId;
98
115
  }
116
+ // Attach inline keyboard only to the last chunk
117
+ if (replyMarkup && chunk === chunks[chunks.length - 1]) {
118
+ body.reply_markup = replyMarkup;
119
+ }
99
120
  const res = await fetch(`${this.baseUrl}/sendMessage`, {
100
121
  method: 'POST',
101
122
  headers: { 'Content-Type': 'application/json' },
@@ -127,6 +148,22 @@ export class TelegramPoller {
127
148
  return firstMessageId;
128
149
  }
129
150
 
151
+ async answerCallbackQuery (callbackQueryId, text) {
152
+ try {
153
+ const body = { callback_query_id: callbackQueryId };
154
+ if (text) {
155
+ body.text = text;
156
+ }
157
+ await fetch(`${this.baseUrl}/answerCallbackQuery`, {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify(body),
161
+ });
162
+ } catch (err) {
163
+ this.logger.error(`answerCallbackQuery error: ${err.message}`);
164
+ }
165
+ }
166
+
130
167
  async deleteMessage (messageId) {
131
168
  if (!messageId) {
132
169
  return;
@@ -237,14 +274,14 @@ function splitMessage (text) {
237
274
  return chunks;
238
275
  }
239
276
 
240
- // Strip ANSI escape codes and common terminal control sequences from PTY output
277
+ // Strip ANSI escape codes and terminal control sequences from PTY output
241
278
  function stripAnsi (text) {
242
279
  return text
243
- // ANSI escape sequences (colors, cursor, etc.)
244
- .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
245
- // OSC sequences (title setting, hyperlinks, etc.)
246
- .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
247
- // Other escape sequences
280
+ // CSI sequences: \x1b[ followed by optional ?/>/! prefix, params, and terminator
281
+ .replace(/\x1b\[[?>=!]?[0-9;]*[a-zA-Z~]/g, '')
282
+ // OSC sequences: \x1b] ... (terminated by BEL or ST)
283
+ .replace(/\x1b][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
284
+ // Other two-char escape sequences (\x1b followed by any single char)
248
285
  .replace(/\x1b[^[\]]/g, '')
249
286
  // Carriage returns (overwrite lines)
250
287
  .replace(/\r/g, '')
@@ -252,4 +289,59 @@ function stripAnsi (text) {
252
289
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
253
290
  }
254
291
 
255
- export { escapeHtml, stripAnsi };
292
+ // Clean PTY output for display: strip ANSI + remove Claude Code UI chrome
293
+ function cleanPtyOutput (raw) {
294
+ const stripped = stripAnsi(raw);
295
+ const lines = stripped.split('\n');
296
+ const cleaned = [];
297
+ for (const line of lines) {
298
+ const trimmed = line.trimEnd();
299
+ // Skip empty lines
300
+ if (!trimmed) {
301
+ continue;
302
+ }
303
+ // Skip Claude Code UI: logo, banner, horizontal rules, prompts, status
304
+ if (/^[▐▝▘▛▜█▌▀▄░▒▓\s]+$/.test(trimmed)) {
305
+ continue;
306
+ }
307
+ if (/^[─━═╌┄]+$/.test(trimmed)) {
308
+ continue;
309
+ }
310
+ if (/^❯\s/.test(trimmed)) {
311
+ continue;
312
+ }
313
+ if (/^[⏵⏴]\s*[⏵⏴]?\s*(bypass|auto|plan|permissions?)/i.test(trimmed)) {
314
+ continue;
315
+ }
316
+ if (/^◐\s/.test(trimmed) || /^\s*◐\s/.test(trimmed)) {
317
+ continue;
318
+ }
319
+ if (/Pasting\s*text/i.test(trimmed)) {
320
+ continue;
321
+ }
322
+ if (/^Claude\s*Code\s*v/i.test(trimmed)) {
323
+ continue;
324
+ }
325
+ if (/Opus|Sonnet|Haiku|Claude\s*Max/i.test(trimmed) && trimmed.length < 80) {
326
+ continue;
327
+ }
328
+ if (/shift\+tab\s*to\s*cycle/i.test(trimmed)) {
329
+ continue;
330
+ }
331
+ if (/ctrl\+[a-z]\s+to\s/i.test(trimmed)) {
332
+ continue;
333
+ }
334
+ if (/^Try\s*"/.test(trimmed)) {
335
+ continue;
336
+ }
337
+ // Skip lines that are mostly box-drawing or block chars (>50%)
338
+ const specialChars = (trimmed.match(/[▐▝▘▛▜█▌▀▄░▒▓─━═╌┄│┃┌┐└┘├┤┬┴┼╔╗╚╝╠╣╦╩╬]/g) || []).length;
339
+ if (specialChars > trimmed.length * 0.5) {
340
+ continue;
341
+ }
342
+ cleaned.push(trimmed);
343
+ }
344
+ return cleaned.join('\n');
345
+ }
346
+
347
+ export { escapeHtml, stripAnsi, cleanPtyOutput };
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.38",
4
+ "version": "1.1.40",
5
5
  "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
6
  "type": "module",
7
7
  "engines": {