claude-notification-plugin 1.1.65 → 1.1.74

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.65",
3
+ "version": "1.1.74",
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
@@ -134,6 +134,9 @@ ENV: `CLAUDE_NOTIFY_VOICE`
134
134
  **notifyOnWaiting** — Notify when Claude is waiting for input. Default: **false**
135
135
  ENV: `CLAUDE_NOTIFY_WAITING`
136
136
 
137
+ **notifyOnPermission** — Notify when Claude asks for tool permission (e.g. file edit confirmation). Default: **true**
138
+ ENV: `CLAUDE_NOTIFY_ON_PERMISSION`
139
+
137
140
  **webhookUrl** — POST notification JSON to this URL. When set, all events (including user prompts) are sent. Set env to empty string (`""`) to disable per-project.
138
141
  ENV: `CLAUDE_NOTIFY_WEBHOOK_URL`
139
142
 
package/commit-sha CHANGED
@@ -1 +1 @@
1
- ca17dbcec65a2871b105feed158245a48fdd5d78
1
+ 40748d991517178165dc7d6f9c3a71b8b1ee7669
@@ -204,6 +204,7 @@ Running two listeners is impossible — the PID file prevents it. And this is im
204
204
  | **WorktreeManager** | `worktree-manager.js` | Creates and removes git worktrees. Auto-discovery via `git worktree list`. Maps `&project/branch` to a path on disk |
205
205
  | **Logger** | `logger.js` | Writes operational log to `~/.claude/.cc-n-listener.log`. Rotation when exceeding 5 MB (old file → `.log.old`) |
206
206
  | **TaskLogger** | `task-logger.js` | Writes task Q&A logs (questions to Claude and answers). Separate file per project/branch. Rotation at 5 MB |
207
+ | **JsonlReader** | `jsonl-reader.js` | Reads Claude Code's structured JSONL session files incrementally. Provides clean, semantic live console content (text responses, tool calls) instead of raw PTY output |
207
208
 
208
209
  ---
209
210
 
@@ -762,14 +763,91 @@ Shows a brief reference for all commands.
762
763
 
763
764
  ### Live console
764
765
 
765
- 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.
766
+ When **`liveConsole`** is enabled (default: `true`), the "⏳ Running..." message in Telegram is periodically updated with what Claude is doing in real-time.
766
767
 
767
- The output is cleaned from ANSI escape codes and Claude Code UI chrome (logo, status bar, prompts), leaving only meaningful content.
768
+ #### Data source: JSONL vs PTY
768
769
 
769
- Configuration:
770
- - `liveConsole` — enable/disable (default: `true`)
771
- - `liveConsoleIntervalMillis`update interval in seconds (default: `1`)
772
- - `liveConsoleMaxOutputChars` — max characters of PTY output to show (default: `300`)
770
+ The live console can read data from two sources:
771
+
772
+ 1. **JSONL session files** (default, preferred) Claude Code writes structured JSONL files to `~/.claude/projects/{encodedCwd}/{sessionId}.jsonl`. Each line is a full JSON message with role, content (text, tool_use, thinking), model, timestamps, and cost. This gives clean, semantic output: the actual text Claude wrote and which tools it called.
773
+
774
+ 2. **PTY buffer** (fallback) — raw terminal output from the pseudo-terminal, cleaned from ANSI escape codes and Claude Code UI chrome. This is a legacy approach that produces noisier output.
775
+
776
+ Controlled by the `liveConsoleSource` config parameter:
777
+ - `"auto"` (default) — try JSONL first, fall back to PTY buffer
778
+ - `"jsonl"` — use only JSONL (no output if file not found)
779
+ - `"pty"` — use only PTY buffer (legacy behavior)
780
+
781
+ #### How the JSONL path is resolved
782
+
783
+ The JSONL file path follows the convention:
784
+ ```
785
+ ~/.claude/projects/{encodedCwd}/{sessionId}.jsonl
786
+ ```
787
+
788
+ **Step 1: Determine `sessionId`**
789
+
790
+ When Claude Code starts a session, its `SessionStart` hook writes a signal file `rdy_{sessionId}.json` to `~/.claude/pty-signals/`. The listener's PTY runner already polls this directory. On receiving a `ready` signal, the runner extracts `sessionId` from the filename and stores it in the session state.
791
+
792
+ **Step 2: Encode `cwd` into a project directory name**
793
+
794
+ Claude Code encodes the working directory path into a folder name by replacing special characters with dashes:
795
+
796
+ | Character | Replacement |
797
+ |---|---|
798
+ | `:` | `-` |
799
+ | `\` | `-` |
800
+ | `/` | `-` |
801
+ | `.` | `-` |
802
+ | `_` | `-` |
803
+
804
+ All other characters (letters, digits, existing `-`) are preserved as-is.
805
+
806
+ Examples:
807
+
808
+ | Working directory | Encoded project dir |
809
+ |---|---|
810
+ | `D:\DEV\FA\_pub\claude-notification-plugin` | `D--DEV-FA--pub-claude-notification-plugin` |
811
+ | `D:\DEV\FA\_cur\work-fast--testing` | `D--DEV-FA--cur-work-fast--testing` |
812
+ | `/home/user/projects/api-server` | `-home-user-projects-api-server` |
813
+
814
+ The double dashes (`--`) arise naturally from consecutive special characters, e.g. `\_` → `-` + `-` = `--`.
815
+
816
+ **Step 3: Construct the full path and open a reader**
817
+
818
+ ```
819
+ sessionId = "08b1fa75-91fe-4c1e-a47e-db9b99af7fb5"
820
+ cwd = "D:\DEV\FA\_pub\claude-notification-plugin"
821
+ ↓ encode
822
+ encodedCwd = "D--DEV-FA--pub-claude-notification-plugin"
823
+ ↓ join
824
+ path = ~/.claude/projects/D--DEV-FA--pub-claude-notification-plugin/08b1fa75-91fe-4c1e-a47e-db9b99af7fb5.jsonl
825
+ ```
826
+
827
+ If `sessionId` is not yet known (session hasn't started), the fallback picks the most recently modified `.jsonl` file in the project directory (within the last 2 minutes).
828
+
829
+ #### What is displayed
830
+
831
+ From the JSONL data, the live console extracts:
832
+ - **Text responses** — the actual text Claude wrote (from `content[].type === "text"`)
833
+ - **Tool calls** — formatted as `🔧 Read: config.json`, `🔧 $ npm test`, `🔧 Grep: pattern`, etc. (from `content[].type === "tool_use"`)
834
+ - **Thinking blocks** are skipped (not shown)
835
+
836
+ This is far more informative than raw PTY output: you see the real tool calls and actual responses, not garbled terminal fragments.
837
+
838
+ #### Incremental reading
839
+
840
+ The JSONL reader tracks its byte offset in the file and only reads new data on each poll. Since JSONL files are append-only, this is safe and efficient — no re-reading of the entire file.
841
+
842
+ #### Configuration
843
+
844
+ | Parameter | Default | Description |
845
+ |---|---|---|
846
+ | `liveConsole` | `true` | Enable/disable live console |
847
+ | `liveConsoleIntervalMillis` | `1000` | Update interval in milliseconds |
848
+ | `liveConsoleMaxOutputChars` | `300` | Max chars of PTY output per update |
849
+ | `liveConsoleSource` | `"auto"` | Data source: `"auto"`, `"jsonl"`, or `"pty"` |
850
+ | `jsonlMaxContentChars` | `500` | Max chars of JSONL content per update |
773
851
 
774
852
  ### PTY logs
775
853
 
@@ -792,8 +870,9 @@ Send `/pty` or `/pty &project` in Telegram to get instant diagnostics:
792
870
  - Buffer size in bytes
793
871
  - Elapsed time since task start
794
872
  - Whether live console interval is active
873
+ - Whether JSONL source is active
795
874
  - Whether PTY log stream is writing
796
- - Last 15 lines of cleaned output
875
+ - Last 15 lines of output (from JSONL if available, otherwise from PTY buffer)
797
876
 
798
877
  ---
799
878
 
@@ -0,0 +1,376 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { CLAUDE_DIR } from '../bin/constants.js';
4
+
5
+ const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
6
+
7
+ /**
8
+ * Encode a cwd path into the Claude Code project directory name.
9
+ * Replaces : \ / . _ with dashes.
10
+ *
11
+ * Examples:
12
+ * D:\DEV\FA\_pub\my-project → D--DEV-FA--pub-my-project
13
+ * /home/user/projects/api → -home-user-projects-api
14
+ */
15
+ export function cwdToProjectDir (cwd) {
16
+ return cwd.replace(/[:\\/._]/g, '-');
17
+ }
18
+
19
+ /**
20
+ * Find the JSONL file path for a given cwd and sessionId.
21
+ * Returns null if the file does not exist.
22
+ */
23
+ export function resolveJsonlPath (cwd, sessionId) {
24
+ if (!sessionId) {
25
+ return null;
26
+ }
27
+ const dirName = cwdToProjectDir(cwd);
28
+ const filePath = path.join(PROJECTS_DIR, dirName, `${sessionId}.jsonl`);
29
+ try {
30
+ fs.accessSync(filePath, fs.constants.R_OK);
31
+ return filePath;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Find the most recently modified JSONL file for a given cwd.
39
+ * Used as a fallback when sessionId is not yet known.
40
+ * Returns null if no fresh file is found within maxAgeMs.
41
+ */
42
+ export function resolveJsonlByMtime (cwd, maxAgeMs = 120_000) {
43
+ const dirName = cwdToProjectDir(cwd);
44
+ const dirPath = path.join(PROJECTS_DIR, dirName);
45
+ let files;
46
+ try {
47
+ files = fs.readdirSync(dirPath);
48
+ } catch {
49
+ return null;
50
+ }
51
+
52
+ let best = null;
53
+ let bestMtime = 0;
54
+ const now = Date.now();
55
+
56
+ for (const f of files) {
57
+ if (!f.endsWith('.jsonl')) {
58
+ continue;
59
+ }
60
+ try {
61
+ const stat = fs.statSync(path.join(dirPath, f));
62
+ if (stat.mtimeMs > bestMtime && now - stat.mtimeMs < maxAgeMs) {
63
+ bestMtime = stat.mtimeMs;
64
+ best = path.join(dirPath, f);
65
+ }
66
+ } catch {
67
+ // ignore
68
+ }
69
+ }
70
+
71
+ return best;
72
+ }
73
+
74
+ /**
75
+ * Incremental JSONL file reader.
76
+ * Reads new lines from a JSONL file since the last read.
77
+ */
78
+ export class JsonlReader {
79
+ constructor (filePath, logger) {
80
+ this.filePath = filePath;
81
+ this.logger = logger || null;
82
+ this._offset = 0;
83
+ this._remainder = '';
84
+ this._lastAssistantText = '';
85
+ this._lastToolUse = null;
86
+ }
87
+
88
+ /**
89
+ * Read new lines since last call, parse JSON, update internal state.
90
+ * Returns array of parsed JSONL objects (only new ones).
91
+ */
92
+ readNew () {
93
+ let fd;
94
+ try {
95
+ fd = fs.openSync(this.filePath, 'r');
96
+ } catch {
97
+ return [];
98
+ }
99
+
100
+ try {
101
+ const stat = fs.fstatSync(fd);
102
+ if (stat.size <= this._offset) {
103
+ return [];
104
+ }
105
+
106
+ const buf = Buffer.alloc(stat.size - this._offset);
107
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, this._offset);
108
+ if (bytesRead === 0) {
109
+ return [];
110
+ }
111
+ this._offset += bytesRead;
112
+
113
+ const chunk = this._remainder + buf.toString('utf-8', 0, bytesRead);
114
+ const lines = chunk.split('\n');
115
+ // Last element may be incomplete — save as remainder
116
+ this._remainder = lines.pop() || '';
117
+
118
+ const entries = [];
119
+ for (const line of lines) {
120
+ const trimmed = line.trim();
121
+ if (!trimmed) {
122
+ continue;
123
+ }
124
+ try {
125
+ const obj = JSON.parse(trimmed);
126
+ entries.push(obj);
127
+ this._processEntry(obj);
128
+ } catch {
129
+ // skip malformed lines
130
+ }
131
+ }
132
+ return entries;
133
+ } finally {
134
+ fs.closeSync(fd);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Process a parsed JSONL entry to update last assistant content.
140
+ */
141
+ _processEntry (entry) {
142
+ if (entry.message?.role !== 'assistant') {
143
+ return;
144
+ }
145
+
146
+ const content = entry.message?.content;
147
+ if (!Array.isArray(content)) {
148
+ return;
149
+ }
150
+
151
+ for (const block of content) {
152
+ if (block.type === 'text' && block.text) {
153
+ this._lastAssistantText = block.text;
154
+ this._lastToolUse = null; // text supersedes tool_use display
155
+ } else if (block.type === 'tool_use') {
156
+ this._lastToolUse = {
157
+ name: block.name || 'unknown',
158
+ input: block.input || {},
159
+ };
160
+ }
161
+ // thinking blocks are ignored for display
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Get formatted display text from the last assistant message.
167
+ * Returns a short summary suitable for Telegram live console.
168
+ */
169
+ getDisplayContent (maxChars = 300) {
170
+ const parts = [];
171
+
172
+ if (this._lastToolUse) {
173
+ parts.push(formatToolUse(this._lastToolUse));
174
+ }
175
+
176
+ if (this._lastAssistantText) {
177
+ parts.push(this._lastAssistantText);
178
+ }
179
+
180
+ if (parts.length === 0) {
181
+ return '';
182
+ }
183
+
184
+ let result = parts.join('\n\n');
185
+ if (result.length > maxChars) {
186
+ result = result.slice(-maxChars);
187
+ // Trim to last complete line
188
+ const nlIdx = result.indexOf('\n');
189
+ if (nlIdx > 0) {
190
+ result = result.slice(nlIdx + 1);
191
+ }
192
+ }
193
+ return result;
194
+ }
195
+
196
+ /**
197
+ * Reset reader to re-read from the beginning.
198
+ */
199
+ reset () {
200
+ this._offset = 0;
201
+ this._remainder = '';
202
+ this._lastAssistantText = '';
203
+ this._lastToolUse = null;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Format a tool_use block into a short display string.
209
+ */
210
+ function formatToolUse (tool) {
211
+ const name = tool.name || '';
212
+ const input = tool.input || {};
213
+ const trunc = (s, n) => (typeof s === 'string' && s.length > n ? s.slice(0, n - 1) + '…' : s);
214
+ switch (name) {
215
+ case 'Read':
216
+ case 'Write':
217
+ case 'Edit':
218
+ return input.file_path
219
+ ? `🔧 ${name}: ${path.basename(input.file_path)}`
220
+ : `🔧 ${name}`;
221
+ case 'Bash':
222
+ return input.command
223
+ ? `🔧 $ ${trunc(input.command, 80)}${input.run_in_background ? ' (bg)' : ''}${input.timeout ? ` (timeout ${input.timeout})` : ''}`
224
+ : '🔧 Bash';
225
+ case 'Grep':
226
+ if (input.pattern) {
227
+ const where = typeof input.path === 'string'
228
+ ? path.basename(input.path)
229
+ : (typeof input.glob === 'string' ? input.glob : '');
230
+
231
+ const flags = [];
232
+ if (input['-n']) flags.push('-n');
233
+ if (input['-C']) flags.push(`-C ${input['-C']}`);
234
+ if (!input['-C'] && (typeof input.context === 'number' || typeof input.context === 'string')) {
235
+ flags.push(`-C ${input.context}`);
236
+ }
237
+ if (input['-i']) flags.push('-i');
238
+ if (input['-A']) flags.push(`-A ${input['-A']}`);
239
+ if (input['-B']) flags.push(`-B ${input['-B']}`);
240
+ if (input.head_limit) flags.push(`head ${input.head_limit}`);
241
+ const flagStr = flags.length ? ` ${flags.join(' ')}` : '';
242
+
243
+ return where
244
+ ? `🔧 Grep${flagStr}: ${trunc(input.pattern, 60)} in ${trunc(where, 30)}`
245
+ : `🔧 Grep${flagStr}: ${trunc(input.pattern, 80)}`;
246
+ }
247
+ return '🔧 Grep';
248
+ case 'Glob':
249
+ if (input.pattern) {
250
+ const p = typeof input.path === 'string' ? path.basename(input.path) : '';
251
+ return p ? `🔧 Glob: ${trunc(input.pattern, 60)} in ${trunc(p, 30)}` : `🔧 Glob: ${trunc(input.pattern, 80)}`;
252
+ }
253
+ return '🔧 Glob';
254
+ case 'Agent':
255
+ if (input.description) {
256
+ const bg = input.run_in_background ? ' (bg)' : '';
257
+ const st = typeof input.subagent_type === 'string' && input.subagent_type.trim()
258
+ ? ` [${input.subagent_type.trim()}]`
259
+ : '';
260
+ return `🔧 Agent${bg}${st}: ${trunc(input.description, 80)}`;
261
+ }
262
+ return '🔧 Agent';
263
+ case 'Skill':
264
+ return input.skill
265
+ ? `🔧 Skill: ${input.skill}${input.args ? ` ${trunc(String(input.args), 80)}` : ''}`
266
+ : '🔧 Skill';
267
+ case 'WebFetch':
268
+ if (input.url) {
269
+ const hasPrompt = typeof input.prompt === 'string' && input.prompt.trim();
270
+ return `🔧 Fetch${hasPrompt ? '*' : ''}: ${trunc(input.url, 80)}`;
271
+ }
272
+ return '🔧 WebFetch';
273
+ case 'WebSearch':
274
+ return input.query ? `🔧 Search: ${input.query}` : '🔧 WebSearch';
275
+ case 'ToolSearch':
276
+ return input.query ? `🔧 ToolSearch: ${trunc(input.query, 200)}` : '🔧 ToolSearch';
277
+ case 'TaskCreate':
278
+ return input.subject
279
+ ? `🔧 Task+: ${trunc(input.subject, 100)}`
280
+ : '🔧 TaskCreate';
281
+ case 'TaskUpdate':
282
+ return input.taskId && input.status
283
+ ? `🔧 Task#${input.taskId}: ${input.status}`
284
+ : (input.taskId ? `🔧 Task#${input.taskId}` : '🔧 TaskUpdate');
285
+ case 'ExitPlanMode':
286
+ return input.planFilePath
287
+ ? `🔧 ExitPlanMode: ${path.basename(input.planFilePath)}`
288
+ : '🔧 ExitPlanMode';
289
+ case 'Task':
290
+ if (input.description) {
291
+ const st = typeof input.subagent_type === 'string' && input.subagent_type.trim()
292
+ ? ` [${input.subagent_type.trim()}]`
293
+ : '';
294
+ return `🔧 Task${st}: ${trunc(input.description, 80)}`;
295
+ }
296
+ return '🔧 Task';
297
+ case 'TaskOutput':
298
+ if (input.task_id) {
299
+ return `🔧 Task#${input.task_id}: output${input.timeout ? ` (timeout ${input.timeout})` : ''}`;
300
+ }
301
+ return '🔧 TaskOutput';
302
+ case 'AskUserQuestion': {
303
+ const qs = Array.isArray(input.questions) ? input.questions : [];
304
+ if (qs.length > 0) {
305
+ const first = qs[0] || {};
306
+ const head = (typeof first.header === 'string' && first.header.trim())
307
+ ? first.header.trim()
308
+ : (typeof first.question === 'string' ? first.question.trim() : '');
309
+ const suffix = qs.length > 1 ? ` (+${qs.length - 1})` : '';
310
+ if (head) {
311
+ return `🔧 Ask: ${trunc(head, 120)}${suffix}`;
312
+ }
313
+ return `🔧 AskUserQuestion${suffix}`;
314
+ }
315
+ return '🔧 AskUserQuestion';
316
+ }
317
+ default:
318
+ if (name.startsWith('mcp__playwright__browser_')) {
319
+ const action = name.slice('mcp__playwright__browser_'.length);
320
+ switch (action) {
321
+ case 'navigate':
322
+ return input.url ? `🔧 PW nav: ${trunc(input.url, 80)}` : '🔧 PW nav';
323
+ case 'click': {
324
+ const ref = typeof input.ref === 'string' ? input.ref : '';
325
+ const el = typeof input.element === 'string' ? input.element : '';
326
+ return `🔧 PW click: ${trunc(ref || el || '', 80)}`.trim();
327
+ }
328
+ case 'type': {
329
+ const ref = typeof input.ref === 'string' ? input.ref : '';
330
+ const text = typeof input.text === 'string' ? input.text : '';
331
+ return `🔧 PW type: ${trunc(ref || '', 40)}${text ? ` (${text.length} chars)` : ''}`.trim();
332
+ }
333
+ case 'take_screenshot':
334
+ return input.filename ? `🔧 PW shot: ${trunc(input.filename, 80)}` : '🔧 PW shot';
335
+ case 'wait_for':
336
+ return input.time ? `🔧 PW wait: ${input.time}` : '🔧 PW wait';
337
+ case 'console_messages':
338
+ return input.level ? `🔧 PW console: ${input.level}` : '🔧 PW console';
339
+ case 'snapshot':
340
+ return '🔧 PW snapshot';
341
+ default:
342
+ return `🔧 PW: ${action}`;
343
+ }
344
+ }
345
+ if (name === 'mcp__sequential-thinking__sequentialthinking') {
346
+ const n = input.thoughtNumber;
347
+ const total = input.totalThoughts;
348
+ const head = (typeof n === 'number' || typeof n === 'string') && (typeof total === 'number' || typeof total === 'string')
349
+ ? `${n}/${total} `
350
+ : '';
351
+ const thought = typeof input.thought === 'string' ? trunc(input.thought.trim(), 80) : '';
352
+ return `🔧 Think: ${head}${thought}`.trim();
353
+ }
354
+ if (name === 'mcp__serena__list_dir') {
355
+ return input.relative_path ? `🔧 Serena ls: ${trunc(input.relative_path, 80)}` : '🔧 Serena ls';
356
+ }
357
+ if (name === 'mcp__serena__get_symbols_overview') {
358
+ return input.relative_path ? `🔧 Serena symbols: ${trunc(input.relative_path, 80)}` : '🔧 Serena symbols';
359
+ }
360
+ if (name.startsWith('mcp__')) {
361
+ if (name === 'mcp__context7__resolve-library-id') {
362
+ return input.libraryName
363
+ ? `🔧 C7 lib: ${trunc(input.libraryName, 60)}`
364
+ : '🔧 C7 resolve';
365
+ }
366
+ if (name === 'mcp__context7__query-docs') {
367
+ const lib = typeof input.libraryId === 'string' ? input.libraryId : '';
368
+ const q = typeof input.query === 'string' ? input.query : '';
369
+ return `🔧 C7 docs: ${trunc(q || lib || '', 80)}`.trim();
370
+ }
371
+ const parts = name.split('__');
372
+ return parts.length >= 3 ? `🔧 MCP ${parts[1]}: ${parts[2]}` : `🔧 ${name}`;
373
+ }
374
+ return `🔧 ${name}`;
375
+ }
376
+ }
@@ -12,6 +12,7 @@ import { PtyRunner } from './pty-runner.js';
12
12
  import { WorktreeManager } from './worktree-manager.js';
13
13
  import { parseMessage, parseTarget } from './message-parser.js';
14
14
  import { CLAUDE_DIR, CONFIG_PATH, LISTENER_LOG_FILENAME } from '../bin/constants.js';
15
+ import { JsonlReader, resolveJsonlPath, resolveJsonlByMtime } from './jsonl-reader.js';
15
16
 
16
17
  // ----------------------
17
18
  // CRASH PROTECTION
@@ -138,6 +139,11 @@ const sessions = new Map();
138
139
  const freshSessionDirs = new Set();
139
140
  // Live console intervals per workDir
140
141
  const liveConsoleTimers = new Map();
142
+ // JSONL readers per workDir (for live console from structured session data)
143
+ const jsonlReaders = new Map();
144
+ // Live console source: "jsonl" | "pty" | "auto" (default: "auto")
145
+ const liveConsoleSource = listenerConfig.liveConsoleSource || 'auto';
146
+ const jsonlMaxContentChars = listenerConfig.jsonlMaxContentChars || 500;
141
147
 
142
148
  logger.info('Listener started');
143
149
  logger.info(`Projects: ${JSON.stringify(Object.keys(listenerConfig.projects))}`);
@@ -322,6 +328,49 @@ function shouldContinueSession (workDir) {
322
328
  return sessions.has(workDir);
323
329
  }
324
330
 
331
+ function _initJsonlReader (workDir) {
332
+ const sessionId = runner.getSessionId(workDir);
333
+ const jsonlPath = sessionId
334
+ ? resolveJsonlPath(workDir, sessionId)
335
+ : resolveJsonlByMtime(workDir);
336
+ if (jsonlPath) {
337
+ const reader = new JsonlReader(jsonlPath, logger);
338
+ jsonlReaders.set(workDir, reader);
339
+ logger.info(`JSONL reader initialized: ${jsonlPath}`);
340
+ return reader;
341
+ }
342
+ return null;
343
+ }
344
+
345
+ function _getJsonlContent (workDir) {
346
+ let reader = jsonlReaders.get(workDir);
347
+ if (!reader) {
348
+ reader = _initJsonlReader(workDir);
349
+ }
350
+ if (!reader) {
351
+ return null;
352
+ }
353
+ reader.readNew();
354
+ return reader.getDisplayContent(jsonlMaxContentChars);
355
+ }
356
+
357
+ function _getPtyContent (workDir) {
358
+ const raw = runner.getBuffer(workDir);
359
+ if (!raw) {
360
+ return null;
361
+ }
362
+ const cleaned = cleanPtyOutput(raw);
363
+ if (!cleaned) {
364
+ return null;
365
+ }
366
+ const tail = cleaned.length > liveConsoleMaxOutputChars
367
+ ? cleaned.slice(-liveConsoleMaxOutputChars)
368
+ : cleaned;
369
+ return cleaned.length > liveConsoleMaxOutputChars
370
+ ? tail.slice(tail.indexOf('\n') + 1)
371
+ : tail;
372
+ }
373
+
325
374
  function startLiveConsole (workDir, messageId, header) {
326
375
  stopLiveConsole(workDir);
327
376
  if (!liveConsoleEnabled || !messageId) {
@@ -330,22 +379,13 @@ function startLiveConsole (workDir, messageId, header) {
330
379
  let lastSentText = '';
331
380
  const timer = setInterval(async () => {
332
381
  try {
333
- const raw = runner.getBuffer(workDir);
334
- if (!raw) {
335
- return;
382
+ let output = null;
383
+ if (liveConsoleSource === 'jsonl' || liveConsoleSource === 'auto') {
384
+ output = _getJsonlContent(workDir);
336
385
  }
337
- const cleaned = cleanPtyOutput(raw);
338
- if (!cleaned) {
339
- return;
386
+ if (!output && (liveConsoleSource === 'pty' || liveConsoleSource === 'auto')) {
387
+ output = _getPtyContent(workDir);
340
388
  }
341
- // Take the tail that fits
342
- const tail = cleaned.length > liveConsoleMaxOutputChars
343
- ? cleaned.slice(-liveConsoleMaxOutputChars)
344
- : cleaned;
345
- // Trim to last complete line if we sliced mid-line
346
- const output = cleaned.length > liveConsoleMaxOutputChars
347
- ? tail.slice(tail.indexOf('\n') + 1)
348
- : tail;
349
389
  if (!output || output === lastSentText) {
350
390
  return;
351
391
  }
@@ -370,6 +410,7 @@ function stopLiveConsole (workDir) {
370
410
  clearInterval(timer);
371
411
  liveConsoleTimers.delete(workDir);
372
412
  }
413
+ jsonlReaders.delete(workDir);
373
414
  }
374
415
 
375
416
  async function startTask (workDir, task) {
@@ -830,17 +871,27 @@ function formatPtyInfo (project, branch, workDir, info) {
830
871
  ? formatDuration(Date.now() - new Date(info.startedAt).getTime())
831
872
  : '-';
832
873
  const liveTimer = liveConsoleTimers.has(workDir) ? '✅' : '❌';
833
- const raw = runner.getBuffer(workDir);
834
- const cleaned = raw ? cleanPtyOutput(raw) : '';
835
- const lastLines = cleaned
836
- ? cleaned.split('\n').slice(-15).join('\n')
837
- : '(empty)';
874
+ const hasJsonl = jsonlReaders.has(workDir) ? '✅' : '❌';
875
+
876
+ // Prefer JSONL content if available, fall back to PTY buffer
877
+ let lastLines = '(empty)';
878
+ const jsonlContent = _getJsonlContent(workDir);
879
+ if (jsonlContent) {
880
+ lastLines = jsonlContent.split('\n').slice(-15).join('\n');
881
+ } else {
882
+ const raw = runner.getBuffer(workDir);
883
+ const cleaned = raw ? cleanPtyOutput(raw) : '';
884
+ if (cleaned) {
885
+ lastLines = cleaned.split('\n').slice(-15).join('\n');
886
+ }
887
+ }
838
888
 
839
889
  return `<b>${escapeHtml(label)}</b>
840
890
  State: <code>${info.state}</code>
841
891
  Buffer: <code>${info.bufferSize}</code> bytes
842
892
  Elapsed: ${elapsed}
843
893
  Live console: ${liveTimer}
894
+ JSONL source: ${hasJsonl}
844
895
  PTY log: <code>${info.hasLogStream ? 'writing' : 'off'}</code>
845
896
 
846
897
  <pre>${escapeHtml(lastLines)}</pre>`;
@@ -135,12 +135,16 @@ export class PtyRunner extends EventEmitter {
135
135
  }
136
136
  }
137
137
  } else if (type === 'ready') {
138
- // SessionStart — emit ready event
138
+ // SessionStart — emit ready event, capture sessionId from filename
139
139
  this._unlinkSafe(filePath);
140
+ const signalSessionId = f.startsWith('rdy_') ? f.slice(4, -5) : null;
140
141
  for (const [workDir, session] of this.sessions) {
141
142
  if (this._normalizePath(session.workDir) === this._normalizePath(cwd)) {
142
143
  session._lastActivityTime = Date.now();
143
144
  session._model = marker.model || '';
145
+ if (signalSessionId && signalSessionId !== 'unknown') {
146
+ session.sessionId = signalSessionId;
147
+ }
144
148
  this.emit('ready', workDir, marker);
145
149
  break;
146
150
  }
@@ -613,6 +617,14 @@ export class PtyRunner extends EventEmitter {
613
617
  return session?._buffer || '';
614
618
  }
615
619
 
620
+ /**
621
+ * Get Claude session ID for a workDir (captured from SessionStart hook signal).
622
+ */
623
+ getSessionId (workDir) {
624
+ const session = this.sessions.get(workDir);
625
+ return session?.sessionId || null;
626
+ }
627
+
616
628
  /**
617
629
  * Get last tool activity for a workDir (from PostToolUse hook signals).
618
630
  */
@@ -63,6 +63,7 @@ function loadConfig () {
63
63
  webhookUrl: '',
64
64
  notifyAfterSeconds: 15,
65
65
  notifyOnWaiting: false,
66
+ notifyOnPermission: true,
66
67
  debug: false,
67
68
  };
68
69
 
@@ -88,6 +89,9 @@ function loadConfig () {
88
89
  if (typeof user.notifyOnWaiting === 'boolean') {
89
90
  config.notifyOnWaiting = user.notifyOnWaiting;
90
91
  }
92
+ if (typeof user.notifyOnPermission === 'boolean') {
93
+ config.notifyOnPermission = user.notifyOnPermission;
94
+ }
91
95
  if (typeof user.debug === 'boolean') {
92
96
  config.debug = user.debug;
93
97
  }
@@ -122,6 +126,9 @@ function loadConfig () {
122
126
  if (process.env.CLAUDE_NOTIFY_WAITING !== undefined) {
123
127
  config.notifyOnWaiting = process.env.CLAUDE_NOTIFY_WAITING === '1';
124
128
  }
129
+ if (process.env.CLAUDE_NOTIFY_ON_PERMISSION !== undefined) {
130
+ config.notifyOnPermission = process.env.CLAUDE_NOTIFY_ON_PERMISSION === '1';
131
+ }
125
132
  if (process.env.CLAUDE_NOTIFY_DEBUG !== undefined) {
126
133
  config.debug = process.env.CLAUDE_NOTIFY_DEBUG === '1';
127
134
  }
@@ -641,11 +648,10 @@ function getVoicePhrase (duration, project) {
641
648
  return fn(duration, project || 'unknown');
642
649
  }
643
650
 
644
- function speakResult (config, duration, project) {
651
+ function speakText (config, text) {
645
652
  if (!config.voice.enabled) {
646
653
  return;
647
654
  }
648
- const text = getVoicePhrase(duration, project);
649
655
  try {
650
656
  switch (PLATFORM) {
651
657
  case 'win32': {
@@ -672,10 +678,26 @@ function speakResult (config, duration, project) {
672
678
  }
673
679
  }
674
680
  } catch (err) {
675
- debugLog(config, 'speakResult failed:', err.message);
681
+ debugLog(config, 'speakText failed:', err.message);
676
682
  }
677
683
  }
678
684
 
685
+ function speakResult (config, duration, project) {
686
+ speakText(config, getVoicePhrase(duration, project));
687
+ }
688
+
689
+ const permissionVoicePhrases = {
690
+ en: (p, tool) => `Claude needs your permission on ${p} for ${tool}`,
691
+ ru: (p, tool) => `Клод ожидает разрешение в проекте ${p} на ${tool}`,
692
+ };
693
+
694
+ function getPermissionVoicePhrase (project, toolName) {
695
+ const locale = Intl.DateTimeFormat().resolvedOptions().locale || 'en';
696
+ const lang = locale.split('-')[0].toLowerCase();
697
+ const fn = permissionVoicePhrases[lang] || permissionVoicePhrases.en;
698
+ return fn(project || 'unknown', toolName || 'unknown');
699
+ }
700
+
679
701
  // ----------------------
680
702
  // READ HOOK INPUT
681
703
  // ----------------------
@@ -760,7 +782,7 @@ process.stdin.on('end', async () => {
760
782
  // STOP / NOTIFICATION EVENT
761
783
  // ----------------------
762
784
 
763
- if (eventType !== 'Stop' && eventType !== 'Notification' && eventType !== 'StopFailure') {
785
+ if (eventType !== 'Stop' && eventType !== 'Notification' && eventType !== 'StopFailure' && eventType !== 'PermissionRequest') {
764
786
  process.exit(0);
765
787
  }
766
788
 
@@ -768,6 +790,10 @@ process.stdin.on('end', async () => {
768
790
  process.exit(0);
769
791
  }
770
792
 
793
+ if (eventType === 'PermissionRequest' && !config.notifyOnPermission) {
794
+ process.exit(0);
795
+ }
796
+
771
797
  let duration = 0;
772
798
  const session = state.sessions[sessionId];
773
799
  if (session?.start) {
@@ -778,8 +804,21 @@ process.stdin.on('end', async () => {
778
804
  process.exit(0);
779
805
  }
780
806
 
781
- const statusEmoji = eventType === 'Notification' ? '⏸' : eventType === 'StopFailure' ? '❌' : '✅';
782
- const desktopStatus = eventType === 'Notification' ? 'Waiting' : eventType === 'StopFailure' ? `Error: ${event.error || 'unknown'}` : 'Finished';
807
+ const permToolName = event.tool_name || 'unknown';
808
+ const permDetail = event.tool_input?.file_path || event.tool_input?.command?.slice(0, 80) || '';
809
+
810
+ const statusEmoji = eventType === 'PermissionRequest' ? '🔐' : eventType === 'Notification' ? '⏸' : eventType === 'StopFailure' ? '❌' : '✅';
811
+
812
+ let desktopStatus;
813
+ if (eventType === 'PermissionRequest') {
814
+ desktopStatus = `Permission: ${permToolName}${permDetail ? ` — ${path.basename(permDetail)}` : ''}`;
815
+ } else if (eventType === 'Notification') {
816
+ desktopStatus = 'Waiting';
817
+ } else if (eventType === 'StopFailure') {
818
+ desktopStatus = `Error: ${event.error || 'unknown'}`;
819
+ } else {
820
+ desktopStatus = 'Finished';
821
+ }
783
822
 
784
823
  const branch = getBranch(cwd);
785
824
  let label = `/${project}`;
@@ -794,8 +833,16 @@ process.stdin.on('end', async () => {
794
833
  const desktopTitle = label;
795
834
  const desktopMessage = desktopStatus;
796
835
 
797
- let telegramMessage =
798
- `${statusEmoji} ${labelHtml}\n(duration: ${duration}s)${triggerLine}`;
836
+ let telegramMessage;
837
+ if (eventType === 'PermissionRequest') {
838
+ telegramMessage = `${statusEmoji} ${labelHtml}\nPermission: <b>${escapeHtml(permToolName)}</b>`;
839
+ if (permDetail) {
840
+ telegramMessage += `\n<code>${escapeHtml(permDetail)}</code>`;
841
+ }
842
+ telegramMessage += `\n(duration: ${duration}s)${triggerLine}`;
843
+ } else {
844
+ telegramMessage = `${statusEmoji} ${labelHtml}\n(duration: ${duration}s)${triggerLine}`;
845
+ }
799
846
 
800
847
  if (config.telegram.includeLastCcMessageInTelegram && event.last_assistant_message) {
801
848
  const maxLen = 3500;
@@ -813,15 +860,24 @@ process.stdin.on('end', async () => {
813
860
  telegramMessage += debugBlockHtml;
814
861
  }
815
862
 
816
- await sendWebhook(config, {
863
+ const webhookPayload = {
817
864
  title: `${desktopStatus}: ${label}`,
818
865
  project,
819
866
  branch: branch || undefined,
820
867
  duration,
821
868
  trigger: eventType,
822
- voicePhrase: config.voice.enabled ? getVoicePhrase(duration, project) : null,
823
869
  hookEvent: event,
824
- });
870
+ };
871
+ if (eventType === 'PermissionRequest') {
872
+ webhookPayload.toolName = permToolName;
873
+ webhookPayload.toolInput = event.tool_input || {};
874
+ }
875
+ if (config.voice.enabled) {
876
+ webhookPayload.voicePhrase = eventType === 'PermissionRequest'
877
+ ? getPermissionVoicePhrase(project, permToolName)
878
+ : getVoicePhrase(duration, project);
879
+ }
880
+ await sendWebhook(config, webhookPayload);
825
881
 
826
882
  state._telegramText = telegramMessage;
827
883
  await sendTelegram(config, state);
@@ -833,5 +889,9 @@ process.stdin.on('end', async () => {
833
889
 
834
890
  await sendDesktopNotification(config, desktopTitle, desktopMessage);
835
891
  playSound(config);
836
- speakResult(config, duration, project);
892
+ if (eventType === 'PermissionRequest') {
893
+ speakText(config, getPermissionVoicePhrase(project, permToolName));
894
+ } else {
895
+ speakResult(config, duration, project);
896
+ }
837
897
  });
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.65",
4
+ "version": "1.1.74",
5
5
  "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
6
  "type": "module",
7
7
  "engines": {