claude-notification-plugin 1.1.104 → 1.1.105

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.104",
3
+ "version": "1.1.105",
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
- 6fb63d8cf4e4026a2909a47213b4303438712d8f
1
+ 172c80dbeb3e3f935e6e96b28d23b7ee40ba8858
@@ -298,7 +298,7 @@ async function notifyTaskCompletion (workDir, task, kind, payload = {}) {
298
298
  runner.cleanActivitySignal(workDir);
299
299
  const entry = queue.queues[workDir];
300
300
  const label = formatLabel(entry?.project, entry?.branch);
301
- const output = payload.text || '';
301
+ let output = payload.text || '';
302
302
 
303
303
  // Build header
304
304
  let header;
@@ -311,12 +311,19 @@ async function notifyTaskCompletion (workDir, task, kind, payload = {}) {
311
311
  header = `⏰ <code>${label}</code>\nTask forcefully stopped — ${reason}`;
312
312
  queueResult = 'TIMEOUT';
313
313
  } else if (task.raw) {
314
- // /clear wipes Claude's context — reset our counters to match.
315
- if ((task.text || '').trim().toLowerCase() === '/clear') {
314
+ const rawCmd = (task.text || '').trim().toLowerCase();
315
+ // /clear wipes Claude's context — reset our counters to match. The PTY
316
+ // buffer after /clear is just an empty prompt + status bar, so skip the
317
+ // body dump and report a clean confirmation instead.
318
+ if (rawCmd === '/clear') {
316
319
  sessions.delete(workDir);
320
+ header = `🧹 <code>${label}</code> <code>/clear</code> — session reset`;
321
+ queueResult = '/clear';
322
+ output = '';
323
+ } else {
324
+ header = `📨 <code>${label}</code> sent <code>${escapeHtml(task.text)}</code>`;
325
+ queueResult = output;
317
326
  }
318
- header = `📨 <code>${label}</code> sent <code>${escapeHtml(task.text)}</code>`;
319
- queueResult = output;
320
327
  } else {
321
328
  // Update session tracking for non-raw completions
322
329
  const session = sessions.get(workDir) || { taskCount: 0 };
@@ -4,7 +4,8 @@ import fs from 'fs';
4
4
  import path from 'path';
5
5
  import { EventEmitter } from 'events';
6
6
  import { PTY_SIGNAL_DIR } from '../bin/constants.js';
7
- import { cleanPtyOutput } from './telegram-poller.js';
7
+ import { cleanRenderedScreen } from './telegram-poller.js';
8
+ import { renderPtyScreen } from './screen-renderer.js';
8
9
 
9
10
  const DEFAULT_TIMEOUT = 600_000; // 10 minutes
10
11
  // Built-in slash-commands (forwarded via %cmd) rarely emit a Stop hook event,
@@ -253,31 +254,37 @@ export class PtyRunner extends EventEmitter {
253
254
  }
254
255
 
255
256
  const CHECK_INTERVAL = 5000;
256
- const checker = setInterval(() => {
257
+ let idleCheckInFlight = false;
258
+ const checker = setInterval(async () => {
257
259
  const lastActivity = session?._lastActivityTime || 0;
258
260
  const idleMs = lastActivity > 0 ? Date.now() - lastActivity : 0;
259
261
 
260
262
  // Idle-prompt fallback: PTY went quiet AND its tail shows Claude's
261
263
  // idle status bar — treat as completed even if the Stop hook never
262
264
  // produced a signal (happens with resumed sessions on Windows ConPTY).
263
- if (idleMs > IDLE_PROMPT_FALLBACK_MS && session) {
264
- const tailRaw = (session._buffer || '').slice(-3000);
265
- const tailClean = cleanPtyOutput(tailRaw);
266
- if (IDLE_PROMPT_RE.test(tailClean)) {
267
- clearInterval(checker);
268
- this.pendingMarkers.delete(pendingId);
269
- const fullClean = cleanPtyOutput(session._buffer || '').trim();
270
- const text = fullClean.length > 2000 ? fullClean.slice(-2000) : fullClean;
271
- this.logger.warn(`PTY idle-prompt fallback completion in ${session.workDir} (no Stop signal in ${Math.round(idleMs / 1000)}s)`);
272
- resolve({
273
- lastAssistantMessage: text,
274
- sessionId: session.sessionId || null,
275
- cost: 0,
276
- numTurns: 0,
277
- durationMs: idleMs,
278
- isIdleFallback: true,
279
- });
280
- return;
265
+ // Rendering is async (xterm-headless), so guard against re-entry while
266
+ // an earlier tick is still rendering.
267
+ if (idleMs > IDLE_PROMPT_FALLBACK_MS && session && !idleCheckInFlight) {
268
+ idleCheckInFlight = true;
269
+ try {
270
+ const rendered = await renderPtyScreen(session._buffer || '');
271
+ if (IDLE_PROMPT_RE.test(rendered)) {
272
+ clearInterval(checker);
273
+ this.pendingMarkers.delete(pendingId);
274
+ const text = cleanRenderedScreen(rendered).trim().slice(-2000);
275
+ this.logger.warn(`PTY idle-prompt fallback completion in ${session.workDir} (no Stop signal in ${Math.round(idleMs / 1000)}s)`);
276
+ resolve({
277
+ lastAssistantMessage: text,
278
+ sessionId: session.sessionId || null,
279
+ cost: 0,
280
+ numTurns: 0,
281
+ durationMs: idleMs,
282
+ isIdleFallback: true,
283
+ });
284
+ return;
285
+ }
286
+ } finally {
287
+ idleCheckInFlight = false;
281
288
  }
282
289
  }
283
290
 
@@ -437,7 +444,7 @@ export class PtyRunner extends EventEmitter {
437
444
  this.taskLogger.logAnswer(task.project || 'unknown', task.branch || 'main', result.text, 0);
438
445
  }
439
446
  this.emit('complete', workDir, task, result);
440
- }).catch((err) => {
447
+ }).catch(async (err) => {
441
448
  session.state = 'idle';
442
449
  session.currentTask = null;
443
450
 
@@ -445,7 +452,11 @@ export class PtyRunner extends EventEmitter {
445
452
  if (task.raw) {
446
453
  // Slash commands (e.g. /clear, /cost) usually don't emit a Stop hook.
447
454
  // Treat inactivity as successful completion and keep the PTY alive.
448
- const cleaned = cleanPtyOutput(session._buffer || '').trim();
455
+ // Render through xterm-headless so the *final* screen is what we
456
+ // report — naive ANSI stripping leaves transient popup rows in the
457
+ // output even though the real terminal has redrawn over them.
458
+ const rendered = await renderPtyScreen(session._buffer || '');
459
+ const cleaned = cleanRenderedScreen(rendered).trim();
449
460
  const tail = cleaned.length > 2000 ? cleaned.slice(-2000) : cleaned;
450
461
  const result = {
451
462
  text: tail,
@@ -0,0 +1,41 @@
1
+ import xtermPkg from '@xterm/headless';
2
+
3
+ const { Terminal } = xtermPkg;
4
+
5
+ // Must match the dimensions PTY sessions are spawned with (pty-runner.js).
6
+ // Mismatch produces wrap artifacts in the rendered viewport.
7
+ const COLS = 120;
8
+ const ROWS = 40;
9
+
10
+ // Run raw PTY bytes through a headless xterm.js so the *final* visible screen
11
+ // is what we read out — cursor positioning, line erases, scroll-up, etc. are
12
+ // all honored. Without this, transient UI (e.g. the slash-command popup that
13
+ // briefly appears while typing `/clear`) leaks into the captured output even
14
+ // though the real terminal has long since cleared those rows.
15
+ //
16
+ // Returns the viewport text (ROWS lines joined by \n) with trailing empty
17
+ // lines trimmed. Scrollback is intentionally ignored — Telegram messages
18
+ // reflect the on-screen state, not the entire session history.
19
+ export async function renderPtyScreen (raw) {
20
+ if (!raw) {
21
+ return '';
22
+ }
23
+ const term = new Terminal({ cols: COLS, rows: ROWS, allowProposedApi: true });
24
+ await new Promise((resolve) => term.write(raw, resolve));
25
+ const buf = term.buffer.active;
26
+ const lines = [];
27
+ const startY = buf.viewportY;
28
+ for (let y = 0; y < ROWS; y++) {
29
+ const line = buf.getLine(startY + y);
30
+ lines.push(line ? line.translateToString(true) : '');
31
+ }
32
+ while (lines.length && !lines[lines.length - 1].trim()) {
33
+ lines.pop();
34
+ }
35
+ if (typeof term.dispose === 'function') {
36
+ term.dispose();
37
+ }
38
+ return lines.join('\n');
39
+ }
40
+
41
+ export { COLS as RENDER_COLS, ROWS as RENDER_ROWS };
@@ -416,4 +416,54 @@ function cleanPtyOutput (raw) {
416
416
  return cleaned.join('\n');
417
417
  }
418
418
 
419
- export { escapeHtml, stripAnsi, cleanPtyOutput };
419
+ // Softer cleaner for *rendered* terminal screens (post xterm-headless emulation).
420
+ // Unlike cleanPtyOutput, this preserves `❯ <command>` lines because the
421
+ // rendered screen already reflects the final state — that prompt line is the
422
+ // actual command echo + result, not a transient input being typed.
423
+ function cleanRenderedScreen (rendered) {
424
+ if (!rendered) {
425
+ return '';
426
+ }
427
+ const lines = rendered.split('\n');
428
+ const cleaned = [];
429
+ for (const line of lines) {
430
+ const trimmed = line.trimEnd();
431
+ if (!trimmed.trim()) {
432
+ continue;
433
+ }
434
+ // Empty prompt arrow (no command on it) — drop
435
+ if (/^❯\s*$/.test(trimmed)) {
436
+ continue;
437
+ }
438
+ // Pure divider lines (≥50% box-drawing chars)
439
+ const specialChars = (trimmed.match(/[▐▝▘▛▜█▌▀▄░▒▓─━═╌┄│┃┌┐└┘├┤┬┴┼╔╗╚╝╠╣╦╩╬]/g) || []).length;
440
+ if (specialChars > trimmed.length * 0.5) {
441
+ continue;
442
+ }
443
+ // Status bar — bypass/auto/plan permissions
444
+ if (/[⏵⏴]\s*[⏵⏴]?\s*(bypass|auto|plan)\s+permissions?/i.test(trimmed)) {
445
+ continue;
446
+ }
447
+ if (/shift\+tab\s*to\s*cycle/i.test(trimmed)) {
448
+ continue;
449
+ }
450
+ if (/ctrl\+[a-z]\s+to\s/i.test(trimmed)) {
451
+ continue;
452
+ }
453
+ // Effort indicator: "◉ xhigh · /effort"
454
+ if (/^\s*◉\s+\S+\s*·\s*\/effort\s*$/.test(trimmed)) {
455
+ continue;
456
+ }
457
+ // Help hint: "/ide for WebStorm" etc. at far-right column
458
+ if (/^\s+\/(ide|model|help)\s+(for|to)\s+/i.test(trimmed)) {
459
+ continue;
460
+ }
461
+ if (/Pasting\s*text/i.test(trimmed)) {
462
+ continue;
463
+ }
464
+ cleaned.push(trimmed);
465
+ }
466
+ return cleaned.join('\n');
467
+ }
468
+
469
+ export { escapeHtml, stripAnsi, cleanPtyOutput, cleanRenderedScreen };
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.104",
4
+ "version": "1.1.105",
5
5
  "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
6
  "type": "module",
7
7
  "engines": {
@@ -55,6 +55,7 @@
55
55
  "access": "public"
56
56
  },
57
57
  "dependencies": {
58
+ "@xterm/headless": "^6.0.0",
58
59
  "node-notifier": "^10.0.1",
59
60
  "node-pty": "^1.1.0"
60
61
  },