claude-notification-plugin 1.1.65 → 1.1.75

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.75",
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/bin/constants.js CHANGED
@@ -1,3 +1,4 @@
1
+ import fs from 'fs';
1
2
  import os from 'os';
2
3
  import path from 'path';
3
4
 
@@ -39,6 +40,31 @@ export const SHORTCUT_DIR = path.join(
39
40
  export const SHORTCUT_PATH = path.join(SHORTCUT_DIR, SHORTCUT_NAME);
40
41
  export const APP_ID = 'Claude Notify';
41
42
 
43
+ /**
44
+ * Find the alias of the project marked as default (isDefault: true).
45
+ * Falls back to the first project key if none is marked.
46
+ */
47
+ export function getDefaultProject (projects) {
48
+ if (!projects || typeof projects !== 'object') {
49
+ return null;
50
+ }
51
+ for (const [alias, proj] of Object.entries(projects)) {
52
+ if (typeof proj === 'object' && proj.isDefault) {
53
+ return alias;
54
+ }
55
+ }
56
+ // Fallback: first project
57
+ const keys = Object.keys(projects);
58
+ return keys.length > 0 ? keys[0] : null;
59
+ }
60
+
61
+ /**
62
+ * Save config object back to CONFIG_PATH.
63
+ */
64
+ export function saveConfig (config) {
65
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
66
+ }
67
+
42
68
  // Plugin identity
43
69
  export const HOOK_COMMAND = 'claude-notify';
44
70
  export const MARKETPLACE_KEY = 'bazilio-plugins';
@@ -6,7 +6,7 @@ import readline from 'readline';
6
6
  import { spawn, execSync } from 'child_process';
7
7
  import { fileURLToPath } from 'url';
8
8
  import {
9
- HOME, CLAUDE_DIR, CONFIG_PATH, PID_PATH, LISTENER_LOG_FILENAME,
9
+ HOME, CLAUDE_DIR, CONFIG_PATH, PID_PATH, LISTENER_LOG_FILENAME, getDefaultProject,
10
10
  } from './constants.js';
11
11
 
12
12
  const __filename = fileURLToPath(import.meta.url);
@@ -116,7 +116,7 @@ async function startDaemon () {
116
116
  console.error(JSON.stringify({
117
117
  listener: {
118
118
  projects: {
119
- default: { path: '/path/to/your/project' },
119
+ myproject: { path: '/path/to/your/project', isDefault: true },
120
120
  },
121
121
  },
122
122
  }, null, 2));
@@ -453,7 +453,10 @@ async function setupListener () {
453
453
  maxTotalTasks: L.maxTotalTasks ?? 50,
454
454
  logDir: L.logDir || path.join(HOME, '.claude'),
455
455
  taskLogDir: L.taskLogDir || path.join(HOME, '.claude'),
456
- projectPath: L.projects?.default?.path || '',
456
+ projectPath: (() => {
457
+ const defAlias = getDefaultProject(L.projects);
458
+ return defAlias ? (L.projects[defAlias]?.path || '') : '';
459
+ })(),
457
460
  };
458
461
 
459
462
  const rl = readline.createInterface({
@@ -520,30 +523,41 @@ Permission mode for claude -p (tools access):
520
523
 
521
524
  // --- Default project ---
522
525
  console.log('');
523
- const projectInput = await ask(rl, `Default project path [${defaults.projectPath || '(none)'}]: `);
524
- const rawProjectPath = projectInput || defaults.projectPath;
525
-
526
526
  L.projects = L.projects || {};
527
+ const currentDefaultAlias = getDefaultProject(L.projects);
528
+ const currentDefaultPath = currentDefaultAlias ? (L.projects[currentDefaultAlias]?.path || '') : '';
529
+
530
+ const projectInput = await ask(rl, `Default project path [${currentDefaultPath || '(none)'}]: `);
531
+ const rawProjectPath = projectInput || currentDefaultPath;
527
532
  let hasValidProject = false;
528
533
 
529
534
  if (rawProjectPath) {
530
535
  const validatedPath = await validateProjectPath(rl, rawProjectPath);
531
536
  if (validatedPath) {
532
- L.projects.default = L.projects.default || {};
533
- L.projects.default.path = validatedPath;
537
+ let aliasForDefault = currentDefaultAlias;
538
+ if (!aliasForDefault) {
539
+ const aliasInput = await ask(rl, 'Alias for default project: ');
540
+ aliasForDefault = aliasInput && isValidAlias(aliasInput) ? aliasInput : 'main';
541
+ }
542
+ L.projects[aliasForDefault] = L.projects[aliasForDefault] || {};
543
+ L.projects[aliasForDefault].path = validatedPath;
544
+ // Set isDefault on this project, clear from others
545
+ for (const proj of Object.values(L.projects)) {
546
+ if (typeof proj === 'object') {
547
+ delete proj.isDefault;
548
+ }
549
+ }
550
+ L.projects[aliasForDefault].isDefault = true;
534
551
  hasValidProject = true;
535
552
  } else {
536
- delete L.projects.default;
537
553
  console.log(' \u26a0 Default project will not be set. Listener will not start without at least one project.');
538
554
  }
539
555
  } else {
540
- delete L.projects.default;
541
556
  console.log(' \u26a0 No default project configured. Listener will not start without at least one project.');
542
557
  }
543
558
 
544
559
  // --- Additional projects loop ---
545
- // Count existing non-default projects
546
- const existingAliases = Object.keys(L.projects).filter(a => a !== 'default');
560
+ const existingAliases = Object.keys(L.projects);
547
561
  if (existingAliases.length > 0) {
548
562
  console.log(`\nExisting projects: ${existingAliases.join(', ')}`);
549
563
  }
@@ -563,10 +577,6 @@ Permission mode for claude -p (tools access):
563
577
  console.log(' \u26a0 Alias cannot be empty.');
564
578
  continue;
565
579
  }
566
- if (alias === 'default') {
567
- console.log(' \u26a0 "default" is reserved. Choose a different name.');
568
- continue;
569
- }
570
580
  if (!isValidAlias(alias)) {
571
581
  console.log(' \u26a0 Invalid alias. Allowed characters: a-z, A-Z, 0-9, -, _');
572
582
  continue;
package/commit-sha CHANGED
@@ -1 +1 @@
1
- ca17dbcec65a2871b105feed158245a48fdd5d78
1
+ 09e7c656d459f08696ec5408b01cb3ca67045d52
@@ -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,388 @@
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']) {
233
+ flags.push('-n');
234
+ }
235
+ if (input['-C']) {
236
+ flags.push(`-C ${input['-C']}`);
237
+ }
238
+ if (!input['-C'] && (typeof input.context === 'number' || typeof input.context === 'string')) {
239
+ flags.push(`-C ${input.context}`);
240
+ }
241
+ if (input['-i']) {
242
+ flags.push('-i');
243
+ }
244
+ if (input['-A']) {
245
+ flags.push(`-A ${input['-A']}`);
246
+ }
247
+ if (input['-B']) {
248
+ flags.push(`-B ${input['-B']}`);
249
+ }
250
+ if (input.head_limit) {
251
+ flags.push(`head ${input.head_limit}`);
252
+ }
253
+ const flagStr = flags.length ? ` ${flags.join(' ')}` : '';
254
+
255
+ return where
256
+ ? `🔧 Grep${flagStr}: ${trunc(input.pattern, 60)} in ${trunc(where, 30)}`
257
+ : `🔧 Grep${flagStr}: ${trunc(input.pattern, 80)}`;
258
+ }
259
+ return '🔧 Grep';
260
+ case 'Glob':
261
+ if (input.pattern) {
262
+ const p = typeof input.path === 'string' ? path.basename(input.path) : '';
263
+ return p ? `🔧 Glob: ${trunc(input.pattern, 60)} in ${trunc(p, 30)}` : `🔧 Glob: ${trunc(input.pattern, 80)}`;
264
+ }
265
+ return '🔧 Glob';
266
+ case 'Agent':
267
+ if (input.description) {
268
+ const bg = input.run_in_background ? ' (bg)' : '';
269
+ const st = typeof input.subagent_type === 'string' && input.subagent_type.trim()
270
+ ? ` [${input.subagent_type.trim()}]`
271
+ : '';
272
+ return `🔧 Agent${bg}${st}: ${trunc(input.description, 80)}`;
273
+ }
274
+ return '🔧 Agent';
275
+ case 'Skill':
276
+ return input.skill
277
+ ? `🔧 Skill: ${input.skill}${input.args ? ` ${trunc(String(input.args), 80)}` : ''}`
278
+ : '🔧 Skill';
279
+ case 'WebFetch':
280
+ if (input.url) {
281
+ const hasPrompt = typeof input.prompt === 'string' && input.prompt.trim();
282
+ return `🔧 Fetch${hasPrompt ? '*' : ''}: ${trunc(input.url, 80)}`;
283
+ }
284
+ return '🔧 WebFetch';
285
+ case 'WebSearch':
286
+ return input.query ? `🔧 Search: ${input.query}` : '🔧 WebSearch';
287
+ case 'ToolSearch':
288
+ return input.query ? `🔧 ToolSearch: ${trunc(input.query, 200)}` : '🔧 ToolSearch';
289
+ case 'TaskCreate':
290
+ return input.subject
291
+ ? `🔧 Task+: ${trunc(input.subject, 100)}`
292
+ : '🔧 TaskCreate';
293
+ case 'TaskUpdate':
294
+ return input.taskId && input.status
295
+ ? `🔧 Task#${input.taskId}: ${input.status}`
296
+ : (input.taskId ? `🔧 Task#${input.taskId}` : '🔧 TaskUpdate');
297
+ case 'ExitPlanMode':
298
+ return input.planFilePath
299
+ ? `🔧 ExitPlanMode: ${path.basename(input.planFilePath)}`
300
+ : '🔧 ExitPlanMode';
301
+ case 'Task':
302
+ if (input.description) {
303
+ const st = typeof input.subagent_type === 'string' && input.subagent_type.trim()
304
+ ? ` [${input.subagent_type.trim()}]`
305
+ : '';
306
+ return `🔧 Task${st}: ${trunc(input.description, 80)}`;
307
+ }
308
+ return '🔧 Task';
309
+ case 'TaskOutput':
310
+ if (input.task_id) {
311
+ return `🔧 Task#${input.task_id}: output${input.timeout ? ` (timeout ${input.timeout})` : ''}`;
312
+ }
313
+ return '🔧 TaskOutput';
314
+ case 'AskUserQuestion': {
315
+ const qs = Array.isArray(input.questions) ? input.questions : [];
316
+ if (qs.length > 0) {
317
+ const first = qs[0] || {};
318
+ const head = (typeof first.header === 'string' && first.header.trim())
319
+ ? first.header.trim()
320
+ : (typeof first.question === 'string' ? first.question.trim() : '');
321
+ const suffix = qs.length > 1 ? ` (+${qs.length - 1})` : '';
322
+ if (head) {
323
+ return `🔧 Ask: ${trunc(head, 120)}${suffix}`;
324
+ }
325
+ return `🔧 AskUserQuestion${suffix}`;
326
+ }
327
+ return '🔧 AskUserQuestion';
328
+ }
329
+ default:
330
+ if (name.startsWith('mcp__playwright__browser_')) {
331
+ const action = name.slice('mcp__playwright__browser_'.length);
332
+ switch (action) {
333
+ case 'navigate':
334
+ return input.url ? `🔧 PW nav: ${trunc(input.url, 80)}` : '🔧 PW nav';
335
+ case 'click': {
336
+ const ref = typeof input.ref === 'string' ? input.ref : '';
337
+ const el = typeof input.element === 'string' ? input.element : '';
338
+ return `🔧 PW click: ${trunc(ref || el || '', 80)}`.trim();
339
+ }
340
+ case 'type': {
341
+ const ref = typeof input.ref === 'string' ? input.ref : '';
342
+ const text = typeof input.text === 'string' ? input.text : '';
343
+ return `🔧 PW type: ${trunc(ref || '', 40)}${text ? ` (${text.length} chars)` : ''}`.trim();
344
+ }
345
+ case 'take_screenshot':
346
+ return input.filename ? `🔧 PW shot: ${trunc(input.filename, 80)}` : '🔧 PW shot';
347
+ case 'wait_for':
348
+ return input.time ? `🔧 PW wait: ${input.time}` : '🔧 PW wait';
349
+ case 'console_messages':
350
+ return input.level ? `🔧 PW console: ${input.level}` : '🔧 PW console';
351
+ case 'snapshot':
352
+ return '🔧 PW snapshot';
353
+ default:
354
+ return `🔧 PW: ${action}`;
355
+ }
356
+ }
357
+ if (name === 'mcp__sequential-thinking__sequentialthinking') {
358
+ const n = input.thoughtNumber;
359
+ const total = input.totalThoughts;
360
+ const head = (typeof n === 'number' || typeof n === 'string') && (typeof total === 'number' || typeof total === 'string')
361
+ ? `${n}/${total} `
362
+ : '';
363
+ const thought = typeof input.thought === 'string' ? trunc(input.thought.trim(), 80) : '';
364
+ return `🔧 Think: ${head}${thought}`.trim();
365
+ }
366
+ if (name === 'mcp__serena__list_dir') {
367
+ return input.relative_path ? `🔧 Serena ls: ${trunc(input.relative_path, 80)}` : '🔧 Serena ls';
368
+ }
369
+ if (name === 'mcp__serena__get_symbols_overview') {
370
+ return input.relative_path ? `🔧 Serena symbols: ${trunc(input.relative_path, 80)}` : '🔧 Serena symbols';
371
+ }
372
+ if (name.startsWith('mcp__')) {
373
+ if (name === 'mcp__context7__resolve-library-id') {
374
+ return input.libraryName
375
+ ? `🔧 C7 lib: ${trunc(input.libraryName, 60)}`
376
+ : '🔧 C7 resolve';
377
+ }
378
+ if (name === 'mcp__context7__query-docs') {
379
+ const lib = typeof input.libraryId === 'string' ? input.libraryId : '';
380
+ const q = typeof input.query === 'string' ? input.query : '';
381
+ return `🔧 C7 docs: ${trunc(q || lib || '', 80)}`.trim();
382
+ }
383
+ const parts = name.split('__');
384
+ return parts.length >= 3 ? `🔧 MCP ${parts[1]}: ${parts[2]}` : `🔧 ${name}`;
385
+ }
386
+ return `🔧 ${name}`;
387
+ }
388
+ }
@@ -11,7 +11,8 @@ import { WorkQueue } from './work-queue.js';
11
11
  import { PtyRunner } from './pty-runner.js';
12
12
  import { WorktreeManager } from './worktree-manager.js';
13
13
  import { parseMessage, parseTarget } from './message-parser.js';
14
- import { CLAUDE_DIR, CONFIG_PATH, LISTENER_LOG_FILENAME } from '../bin/constants.js';
14
+ import { CLAUDE_DIR, CONFIG_PATH, LISTENER_LOG_FILENAME, getDefaultProject, saveConfig } 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) {
@@ -473,6 +514,8 @@ async function handleCommand (cmd, args) {
473
514
  return handleNewSession(args);
474
515
  case '/projects':
475
516
  return handleProjects();
517
+ case '/setdefault':
518
+ return handleSetDefault(args);
476
519
  case '/worktrees':
477
520
  return handleWorktrees(args);
478
521
  case '/worktree':
@@ -611,7 +654,7 @@ function handleQueue () {
611
654
 
612
655
  async function handleCancel (args) {
613
656
  const target = parseTarget(args);
614
- const projectAlias = target?.project || 'default';
657
+ const projectAlias = target?.project || getDefaultProject(listenerConfig.projects);
615
658
  const branch = target?.branch || null;
616
659
 
617
660
  let workDir;
@@ -662,7 +705,7 @@ function handleDrop (args) {
662
705
 
663
706
  function handleClear (args) {
664
707
  const target = parseTarget(args);
665
- const projectAlias = target?.project || 'default';
708
+ const projectAlias = target?.project || getDefaultProject(listenerConfig.projects);
666
709
  const branch = target?.branch || null;
667
710
 
668
711
  let workDir;
@@ -685,7 +728,7 @@ function handleClear (args) {
685
728
 
686
729
  function handleNewSession (args) {
687
730
  const target = parseTarget(args);
688
- const projectAlias = target?.project || 'default';
731
+ const projectAlias = target?.project || getDefaultProject(listenerConfig.projects);
689
732
  const branch = target?.branch || null;
690
733
 
691
734
  let workDir;
@@ -710,10 +753,12 @@ function handleNewSession (args) {
710
753
 
711
754
  function handleProjects () {
712
755
  const projects = listenerConfig.projects;
756
+ const defaultAlias = getDefaultProject(projects);
713
757
  let text = '📂 <b>Projects:</b>\n';
714
758
  for (const [alias, proj] of Object.entries(projects)) {
715
759
  const projPath = typeof proj === 'string' ? proj : proj.path;
716
- text += `\n<b>&${escapeHtml(alias)}</b> <code>${escapeHtml(projPath)}</code>`;
760
+ const icon = alias === defaultAlias ? '🏠 ' : '';
761
+ text += `\n${icon}<b>&${escapeHtml(alias)}</b> → <code>${escapeHtml(projPath)}</code>`;
717
762
  const worktrees = typeof proj === 'object' ? proj.worktrees : null;
718
763
  if (worktrees && Object.keys(worktrees).length > 0) {
719
764
  for (const [branch, wtPath] of Object.entries(worktrees)) {
@@ -721,7 +766,63 @@ function handleProjects () {
721
766
  }
722
767
  }
723
768
  }
724
- return text;
769
+
770
+ const buttons = [];
771
+ // "Set Default" button
772
+ buttons.push([{ text: '🏠 Set Default', callback_data: '/setdefault' }]);
773
+
774
+ return { text, replyMarkup: { inline_keyboard: buttons } };
775
+ }
776
+
777
+ function handleSetDefault (args) {
778
+ const projects = listenerConfig.projects;
779
+
780
+ // No args — show inline keyboard with project list
781
+ if (!args || !args.trim()) {
782
+ const defaultAlias = getDefaultProject(projects);
783
+ const buttons = [];
784
+ for (const [alias, proj] of Object.entries(projects)) {
785
+ const projPath = typeof proj === 'string' ? proj : proj.path;
786
+ const icon = alias === defaultAlias ? '🏠 ' : '';
787
+ buttons.push([{
788
+ text: `${icon}${alias} — ${projPath}`,
789
+ callback_data: `/setdefault ${alias}`,
790
+ }]);
791
+ }
792
+ return {
793
+ text: '🏠 <b>Select default project:</b>',
794
+ replyMarkup: { inline_keyboard: buttons },
795
+ };
796
+ }
797
+
798
+ // Args provided — set the default
799
+ const alias = args.trim();
800
+ if (!projects[alias]) {
801
+ return `❌ Project "<b>${escapeHtml(alias)}</b>" not found. Use /projects to list.`;
802
+ }
803
+
804
+ // Clear isDefault from all projects, set on chosen
805
+ for (const proj of Object.values(projects)) {
806
+ if (typeof proj === 'object') {
807
+ delete proj.isDefault;
808
+ }
809
+ }
810
+ const proj = projects[alias];
811
+ if (typeof proj === 'object') {
812
+ proj.isDefault = true;
813
+ }
814
+
815
+ // Persist to config file
816
+ try {
817
+ saveConfig(config);
818
+ logger.info(`Default project changed to "${alias}"`);
819
+ } catch (err) {
820
+ logger.error(`Failed to save config: ${err.message}`);
821
+ return `❌ Failed to save config: ${escapeHtml(err.message)}`;
822
+ }
823
+
824
+ const projPath = typeof proj === 'string' ? proj : proj.path;
825
+ return `✅ Default project: <b>&${escapeHtml(alias)}</b> → <code>${escapeHtml(projPath)}</code>`;
725
826
  }
726
827
 
727
828
  function handleWorktrees (args) {
@@ -830,17 +931,27 @@ function formatPtyInfo (project, branch, workDir, info) {
830
931
  ? formatDuration(Date.now() - new Date(info.startedAt).getTime())
831
932
  : '-';
832
933
  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)';
934
+ const hasJsonl = jsonlReaders.has(workDir) ? '✅' : '❌';
935
+
936
+ // Prefer JSONL content if available, fall back to PTY buffer
937
+ let lastLines = '(empty)';
938
+ const jsonlContent = _getJsonlContent(workDir);
939
+ if (jsonlContent) {
940
+ lastLines = jsonlContent.split('\n').slice(-15).join('\n');
941
+ } else {
942
+ const raw = runner.getBuffer(workDir);
943
+ const cleaned = raw ? cleanPtyOutput(raw) : '';
944
+ if (cleaned) {
945
+ lastLines = cleaned.split('\n').slice(-15).join('\n');
946
+ }
947
+ }
838
948
 
839
949
  return `<b>${escapeHtml(label)}</b>
840
950
  State: <code>${info.state}</code>
841
951
  Buffer: <code>${info.bufferSize}</code> bytes
842
952
  Elapsed: ${elapsed}
843
953
  Live console: ${liveTimer}
954
+ JSONL source: ${hasJsonl}
844
955
  PTY log: <code>${info.hasLogStream ? 'writing' : 'off'}</code>
845
956
 
846
957
  <pre>${escapeHtml(lastLines)}</pre>`;
@@ -880,6 +991,9 @@ const MENU_KEYBOARD = {
880
991
  [
881
992
  { text: '📜 History', callback_data: '/history' },
882
993
  { text: '🖥 PTY', callback_data: '/pty' },
994
+ { text: '🏠 Default', callback_data: '/setdefault' },
995
+ ],
996
+ [
883
997
  { text: '📖 Help', callback_data: '/help' },
884
998
  ],
885
999
  ],
@@ -897,6 +1011,7 @@ function handleHelp () {
897
1011
  /clear &project[/branch] — clear queue + reset session
898
1012
  /newsession [&project[/branch]] — reset session (keep queue)
899
1013
  /projects — list projects
1014
+ /setdefault — change default project
900
1015
  /worktrees &project — project worktrees
901
1016
  /worktree &project/branch — create worktree
902
1017
  /rmworktree &project/branch — remove worktree
@@ -1014,7 +1129,7 @@ async function mainLoop () {
1014
1129
  await poller.answerCallbackQuery(msg.callbackQueryId);
1015
1130
  }
1016
1131
 
1017
- const parsed = parseMessage(msg.text);
1132
+ const parsed = parseMessage(msg.text, getDefaultProject(listenerConfig.projects));
1018
1133
  if (!parsed) {
1019
1134
  continue;
1020
1135
  }
@@ -1048,6 +1163,7 @@ async function mainLoop () {
1048
1163
  { command: 'status', description: 'Status of all projects' },
1049
1164
  { command: 'queue', description: 'Show all queues' },
1050
1165
  { command: 'projects', description: 'List projects' },
1166
+ { command: 'setdefault', description: 'Change default project' },
1051
1167
  { command: 'history', description: 'Recent task history' },
1052
1168
  { command: 'pty', description: 'PTY session diagnostics' },
1053
1169
  { command: 'help', description: 'Show all commands' },
@@ -7,12 +7,15 @@
7
7
  * /command args → { type: 'command', cmd, args }
8
8
  * &project/branch text → { type: 'task', project, branch, text }
9
9
  * &project text → { type: 'task', project, branch: null, text }
10
- * text → { type: 'task', project: 'default', branch: null, text }
10
+ * text → { type: 'task', project: <defaultProject>, branch: null, text }
11
11
  *
12
12
  * Any /word is treated as a command (known or unknown).
13
13
  * Project designation uses & prefix: &project or &project/branch.
14
+ *
15
+ * @param {string} text - The message text.
16
+ * @param {string} [defaultProject] - Alias of the default project (used for plain text tasks).
14
17
  */
15
- export function parseMessage (text) {
18
+ export function parseMessage (text, defaultProject) {
16
19
  if (!text || typeof text !== 'string') {
17
20
  return null;
18
21
  }
@@ -61,7 +64,7 @@ export function parseMessage (text) {
61
64
  // Plain text → default project
62
65
  return {
63
66
  type: 'task',
64
- project: 'default',
67
+ project: defaultProject || 'default',
65
68
  branch: null,
66
69
  text: trimmed,
67
70
  };
@@ -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.75",
5
5
  "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
6
  "type": "module",
7
7
  "engines": {