claude-notification-plugin 1.0.78 → 1.0.82

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.0.78",
3
+ "version": "1.0.82",
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
@@ -20,6 +20,8 @@ Cross-platform notifications for Claude Code task completion. Sends alerts to Te
20
20
 
21
21
  Auto-updates, seamless integration with the plugin ecosystem.
22
22
 
23
+ These commands are run in the Claude Code terminal:
24
+
23
25
  ```shell
24
26
  /plugin marketplace add Bazilio-san/claude-plugins
25
27
  /plugin install claude-notification-plugin@bazilio-plugins
@@ -239,6 +241,26 @@ Alternative for Chat ID: add **@userinfobot** to a chat and it will reply with t
239
241
 
240
242
  Your remote control for Claude: send a message in Telegram, and the task starts running on your PC. See **[LISTENER.md](LISTENER.md)** for the full guide.
241
243
 
244
+ When installed as a plugin, you can manage the listener daemon directly from Claude Code with the `/listener` slash command.
245
+ These commands are run in the Claude Code terminal:
246
+ `/claude-notification-plugin:listener start | stop | status | logs | restart`
247
+
248
+ ## CLI Commands Registration
249
+
250
+ When installed as a **plugin**, CLI commands (`claude-notify-listener`, etc.) are not in PATH.
251
+ The `/claude-notification-plugin:setup` command registers them automatically by
252
+ placing wrapper scripts next to the `claude` binary.
253
+
254
+ To manage manually:
255
+
256
+ ```bash
257
+ claude-notify-register # register CLI commands
258
+ claude-notify-register list # show registration status
259
+ claude-notify-register unregister # remove CLI commands
260
+ ```
261
+
262
+ When installed via `npm install -g`, npm links the commands itself — no extra step needed.
263
+
242
264
  ## Testing (load without install)
243
265
 
244
266
  ```bash
package/bin/install.js CHANGED
@@ -166,6 +166,16 @@ async function main () {
166
166
  notifyAfterSeconds: 15,
167
167
  notifyOnWaiting: false,
168
168
  debug: false,
169
+ listener: {
170
+ projects: {},
171
+ worktreeBaseDir: path.join(home, '.claude', 'worktrees'),
172
+ autoCreateWorktree: true,
173
+ taskTimeoutMinutes: 30,
174
+ maxQueuePerWorkDir: 10,
175
+ maxTotalTasks: 50,
176
+ logDir: '',
177
+ taskLogDir: '',
178
+ },
169
179
  };
170
180
 
171
181
  // Merge defaults with existing config (user values take priority)
@@ -9,9 +9,19 @@ import { fileURLToPath } from 'url';
9
9
  const __filename = fileURLToPath(import.meta.url);
10
10
  const __dirname = path.dirname(__filename);
11
11
 
12
+ const DEFAULT_LOG_DIR = path.join(os.homedir(), '.claude');
12
13
  const PID_FILE = path.join(os.homedir(), '.claude', '.listener.pid');
13
- const LOG_FILE = path.join(os.homedir(), '.claude', '.listener.log');
14
14
  const CONFIG_FILE = path.join(os.homedir(), '.claude', 'notifier.config.json');
15
+
16
+ function getLogFile () {
17
+ try {
18
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
19
+ const logDir = cfg.listener?.logDir || DEFAULT_LOG_DIR;
20
+ return path.join(logDir, '.cc-n-listener.log');
21
+ } catch {
22
+ return path.join(DEFAULT_LOG_DIR, '.cc-n-listener.log');
23
+ }
24
+ }
15
25
  const LISTENER_SCRIPT = path.join(__dirname, '..', 'listener', 'listener.js');
16
26
 
17
27
  const command = process.argv[2];
@@ -101,10 +111,11 @@ function startDaemon () {
101
111
  }
102
112
 
103
113
  // Ensure log directory exists
104
- fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
114
+ const logFile = getLogFile();
115
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
105
116
 
106
117
  // Open log file for child stdio
107
- const logFd = fs.openSync(LOG_FILE, 'a');
118
+ const logFd = fs.openSync(logFile, 'a');
108
119
 
109
120
  // Spawn detached child
110
121
  const child = spawn(process.execPath, [LISTENER_SCRIPT], {
@@ -122,7 +133,7 @@ function startDaemon () {
122
133
  fs.writeFileSync(PID_FILE, String(child.pid));
123
134
 
124
135
  console.log(`Listener started (PID: ${child.pid})`);
125
- console.log(`Log: ${LOG_FILE}`);
136
+ console.log(`Log: ${logFile}`);
126
137
  console.log(`Projects: ${Object.keys(config.listener.projects).join(', ')}`);
127
138
  }
128
139
 
@@ -179,13 +190,14 @@ function showStatus () {
179
190
  return;
180
191
  }
181
192
 
193
+ const logFile = getLogFile();
182
194
  console.log(`Status: running (PID: ${pid})`);
183
- console.log(`Log: ${LOG_FILE}`);
195
+ console.log(`Log: ${logFile}`);
184
196
 
185
197
  // Show last few log lines
186
198
  try {
187
- if (fs.existsSync(LOG_FILE)) {
188
- const content = fs.readFileSync(LOG_FILE, 'utf-8');
199
+ if (fs.existsSync(logFile)) {
200
+ const content = fs.readFileSync(logFile, 'utf-8');
189
201
  const lines = content.trim().split('\n');
190
202
  const last = lines.slice(-5);
191
203
  console.log('\nRecent log:');
@@ -199,12 +211,13 @@ function showStatus () {
199
211
  }
200
212
 
201
213
  function showLogs () {
214
+ const logFile = getLogFile();
202
215
  try {
203
- if (!fs.existsSync(LOG_FILE)) {
216
+ if (!fs.existsSync(logFile)) {
204
217
  console.log('No log file found');
205
218
  return;
206
219
  }
207
- const content = fs.readFileSync(LOG_FILE, 'utf-8');
220
+ const content = fs.readFileSync(logFile, 'utf-8');
208
221
  const lines = content.trim().split('\n');
209
222
  const last = lines.slice(-50);
210
223
  for (const line of last) {
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { execSync } from 'child_process';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+
11
+ const PLUGIN_ROOT = path.resolve(__dirname, '..');
12
+
13
+ const BINS = {
14
+ 'claude-notify-listener': 'bin/listener-cli.js',
15
+ 'claude-notify-install': 'bin/install.js',
16
+ 'claude-notify-uninstall': 'bin/uninstall.js',
17
+ };
18
+
19
+ function findClaudeDir () {
20
+ try {
21
+ const cmd = process.platform === 'win32' ? 'where claude' : 'which claude';
22
+ const result = execSync(cmd, { encoding: 'utf-8', windowsHide: true }).trim();
23
+ // 'where' on Windows may return multiple lines
24
+ const first = result.split('\n')[0].trim();
25
+ return path.dirname(first);
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function register () {
32
+ const claudeDir = findClaudeDir();
33
+ if (!claudeDir) {
34
+ console.error('Could not find "claude" in PATH.');
35
+ console.error('Make sure Claude Code CLI is installed and available.');
36
+ process.exit(1);
37
+ }
38
+
39
+ const isWindows = process.platform === 'win32';
40
+ const created = [];
41
+
42
+ for (const [name, relPath] of Object.entries(BINS)) {
43
+ const scriptPath = path.join(PLUGIN_ROOT, relPath);
44
+
45
+ if (!fs.existsSync(scriptPath)) {
46
+ console.warn(` Skip ${name}: ${scriptPath} not found`);
47
+ continue;
48
+ }
49
+
50
+ if (isWindows) {
51
+ const cmdFile = path.join(claudeDir, `${name}.cmd`);
52
+ const content = `@echo off\r\nnode "${scriptPath}" %*\r\n`;
53
+ fs.writeFileSync(cmdFile, content);
54
+ created.push(cmdFile);
55
+ } else {
56
+ const shFile = path.join(claudeDir, name);
57
+ const content = `#!/bin/sh\nexec node "${scriptPath}" "$@"\n`;
58
+ fs.writeFileSync(shFile, content, { mode: 0o755 });
59
+ created.push(shFile);
60
+ }
61
+ }
62
+
63
+ if (created.length > 0) {
64
+ console.log('Registered CLI commands:');
65
+ for (const f of created) {
66
+ console.log(` ${f}`);
67
+ }
68
+ } else {
69
+ console.log('No commands were registered.');
70
+ }
71
+ }
72
+
73
+ function list () {
74
+ const claudeDir = findClaudeDir();
75
+ if (!claudeDir) {
76
+ console.log('Claude CLI not found in PATH.');
77
+ return;
78
+ }
79
+
80
+ const isWindows = process.platform === 'win32';
81
+ const ext = isWindows ? '.cmd' : '';
82
+ let found = 0;
83
+
84
+ for (const name of Object.keys(BINS)) {
85
+ const filePath = path.join(claudeDir, `${name}${ext}`);
86
+ const exists = fs.existsSync(filePath);
87
+ console.log(` ${exists ? '+' : '-'} ${name}${exists ? ` (${filePath})` : ''}`);
88
+ if (exists) {
89
+ found++;
90
+ }
91
+ }
92
+
93
+ console.log('');
94
+ console.log(`${found}/${Object.keys(BINS).length} commands registered in ${claudeDir}`);
95
+ }
96
+
97
+ function unregister () {
98
+ const claudeDir = findClaudeDir();
99
+ if (!claudeDir) {
100
+ console.log('Claude CLI not found in PATH — nothing to unregister.');
101
+ return;
102
+ }
103
+
104
+ const isWindows = process.platform === 'win32';
105
+ const removed = [];
106
+
107
+ for (const name of Object.keys(BINS)) {
108
+ const ext = isWindows ? '.cmd' : '';
109
+ const filePath = path.join(claudeDir, `${name}${ext}`);
110
+ if (fs.existsSync(filePath)) {
111
+ fs.unlinkSync(filePath);
112
+ removed.push(filePath);
113
+ }
114
+ }
115
+
116
+ if (removed.length > 0) {
117
+ console.log('Removed CLI commands:');
118
+ for (const f of removed) {
119
+ console.log(` ${f}`);
120
+ }
121
+ } else {
122
+ console.log('No CLI commands found to remove.');
123
+ }
124
+ }
125
+
126
+ const action = process.argv[2];
127
+
128
+ if (action === 'unregister' || action === 'remove') {
129
+ unregister();
130
+ } else if (action === 'list' || action === 'status') {
131
+ list();
132
+ } else if (action === 'register' || !action) {
133
+ register();
134
+ } else {
135
+ console.log('Usage: claude-notify-register <register|unregister|list>');
136
+ console.log('');
137
+ console.log('Commands:');
138
+ console.log(' register Register CLI commands next to claude binary (default)');
139
+ console.log(' unregister Remove registered CLI commands');
140
+ console.log(' list Show registration status of CLI commands');
141
+ process.exit(1);
142
+ }
package/bin/uninstall.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import fs from 'fs';
4
4
  import os from 'os';
5
5
  import path from 'path';
6
+ import { execSync } from 'child_process';
6
7
 
7
8
  const home = os.homedir();
8
9
  const claudeDir = path.join(home, '.claude');
@@ -46,6 +47,26 @@ for (const file of [configPath, statePath]) {
46
47
  }
47
48
  }
48
49
 
50
+ // Remove CLI wrapper scripts
51
+ const CLI_BINS = ['claude-notify-listener', 'claude-notify-install', 'claude-notify-uninstall'];
52
+ let cliBinsRemoved = false;
53
+ try {
54
+ const cmd = process.platform === 'win32' ? 'where claude' : 'which claude';
55
+ const claudeBin = execSync(cmd, { encoding: 'utf-8', windowsHide: true }).trim().split('\n')[0].trim();
56
+ const claudeBinDir = path.dirname(claudeBin);
57
+ const ext = process.platform === 'win32' ? '.cmd' : '';
58
+
59
+ for (const name of CLI_BINS) {
60
+ const filePath = path.join(claudeBinDir, `${name}${ext}`);
61
+ if (fs.existsSync(filePath)) {
62
+ fs.unlinkSync(filePath);
63
+ cliBinsRemoved = true;
64
+ }
65
+ }
66
+ } catch {
67
+ // claude not in PATH — nothing to remove
68
+ }
69
+
49
70
  // Remove plugin cache (old versions left by Claude Code)
50
71
  const pluginCacheDir = path.join(claudeDir, 'plugins', 'cache', 'bazilio-plugins', 'claude-notification-plugin');
51
72
  let cacheRemoved = false;
@@ -61,4 +82,7 @@ console.log('Config files deleted.');
61
82
  if (cacheRemoved) {
62
83
  console.log('Plugin cache cleaned.');
63
84
  }
85
+ if (cliBinsRemoved) {
86
+ console.log('CLI wrapper scripts removed.');
87
+ }
64
88
  console.log('');
package/commands/setup.md CHANGED
@@ -38,7 +38,15 @@ Help the user configure the notification plugin. The config file is `~/.claude/n
38
38
  }
39
39
  ```
40
40
 
41
- 5. **Confirm** — show the user a summary of what was saved.
41
+ 5. **Register CLI commands** — so that `claude-notify-listener`, `claude-notify-install`, and `claude-notify-uninstall` are available from the terminal (not just inside Claude).
42
+
43
+ Run in a shell:
44
+ ```bash
45
+ node "${CLAUDE_PLUGIN_ROOT}/bin/register-cli.js"
46
+ ```
47
+ This finds the directory where `claude` is installed and creates wrapper scripts there. Show the output to the user. If registration fails (e.g. `claude` not in PATH), inform the user but do NOT treat it as a fatal error — the rest of setup is still valid.
48
+
49
+ 6. **Confirm** — show the user a summary of what was saved.
42
50
 
43
51
  ## Important
44
52
 
@@ -201,7 +201,8 @@ Running two listeners is impossible — the PID file prevents it. And this is im
201
201
  | **WorkQueue** | `work-queue.js` | Manages task queues. Each working directory has a separate FIFO queue. Guarantees: one `claude` process per directory. Persists state to disk |
202
202
  | **TaskRunner** | `task-runner.js` | Runs `claude -p "task"` as a child process. Monitors timeouts. Can kill the process on cancellation. Emits events: complete, error, timeout |
203
203
  | **WorktreeManager** | `worktree-manager.js` | Creates and removes git worktrees. Auto-discovery via `git worktree list`. Maps `@project/branch` to a path on disk |
204
- | **Logger** | `logger.js` | Writes log to `~/.claude/.listener.log`. Rotation when exceeding 5 MB (old file → `.log.old`) |
204
+ | **Logger** | `logger.js` | Writes operational log to `~/.claude/.cc-n-listener.log`. Rotation when exceeding 5 MB (old file → `.log.old`) |
205
+ | **TaskLogger** | `task-logger.js` | Writes task Q&A logs (questions to Claude and answers). Separate file per project/branch. Rotation at 5 MB |
205
206
 
206
207
  ---
207
208
 
@@ -304,7 +305,9 @@ Full example of `~/.claude/notifier.config.json` with the listener section:
304
305
  "autoCreateWorktree": true,
305
306
  "taskTimeoutMinutes": 30,
306
307
  "maxQueuePerWorkDir": 10,
307
- "maxTotalTasks": 50
308
+ "maxTotalTasks": 50,
309
+ "logDir": "~/.claude",
310
+ "taskLogDir": "~/.claude"
308
311
  }
309
312
  }
310
313
  ```
@@ -319,6 +322,8 @@ Full example of `~/.claude/notifier.config.json` with the listener section:
319
322
  | `taskTimeoutMinutes` | `30` | Maximum task execution time in minutes. Force-stopped when exceeded |
320
323
  | `maxQueuePerWorkDir` | `10` | Maximum tasks in the queue for a single working directory |
321
324
  | `maxTotalTasks` | `50` | Maximum tasks across all queues combined |
325
+ | `logDir` | `~/.claude` | Directory for the listener operational log (`.cc-n-listener.log`) |
326
+ | `taskLogDir` | same as `logDir` | Directory for task Q&A logs (`.cc-n-task-*.log`). Each project/branch gets its own file |
322
327
 
323
328
  ### What is `projects`?
324
329
 
@@ -392,7 +397,7 @@ If the worktree doesn't exist, it will be created automatically.
392
397
  5. When Claude finishes → sends the result to Telegram
393
398
  6. If there's a next task in the queue → starts it
394
399
 
395
- ---
400
+ ---RFr
396
401
 
397
402
  ## Projects and worktrees
398
403
 
@@ -750,6 +755,44 @@ Handling long responses:
750
755
 
751
756
  ---
752
757
 
758
+ ## Logs
759
+
760
+ The listener writes two types of logs:
761
+
762
+ ### Listener log (operational)
763
+
764
+ Internal events: startup, incoming messages, task lifecycle, errors.
765
+ **Does NOT contain** Claude's questions and answers.
766
+
767
+ Default path: `~/.claude/.cc-n-listener.log`
768
+ Customizable via `listener.logDir` in config.
769
+ Rotation: 5 MB (old file → `.log.old`).
770
+
771
+ ### Task logs (Q&A with Claude)
772
+
773
+ Each project/branch gets its own log file with full questions sent to Claude and answers received.
774
+
775
+ Default directory: same as `listener.logDir` (i.e. `~/.claude/`)
776
+ Customizable via `listener.taskLogDir` in config.
777
+ Filename pattern: `.cc-n-task-<project>[_<branch>].log`
778
+ Rotation: 5 MB per file (old file → `.log.old`).
779
+
780
+ Each entry includes a timestamp, working directory, task text (question), and Claude's full response (answer).
781
+
782
+ Example config to customize both log directories:
783
+
784
+ ```json
785
+ {
786
+ "listener": {
787
+ "logDir": "/var/log/claude-listener",
788
+ "taskLogDir": "/var/log/claude-listener/tasks",
789
+ "projects": { ... }
790
+ }
791
+ }
792
+ ```
793
+
794
+ ---
795
+
753
796
  ## State files
754
797
 
755
798
  All files are stored in `~/.claude/`:
@@ -757,7 +800,8 @@ All files are stored in `~/.claude/`:
757
800
  | File | Description |
758
801
  |---|---|
759
802
  | `.listener.pid` | PID of the running daemon. On `start`, it checks whether the process is alive |
760
- | `.listener.log` | Operation log. Rotation when exceeding 5 MB (old file → `.log.old`) |
803
+ | `.cc-n-listener.log` | Operational log. Rotation when exceeding 5 MB (old file → `.log.old`) |
804
+ | `.cc-n-task-*.log` | Task Q&A logs, one per project/branch. Rotation at 5 MB each |
761
805
  | `.task_queues.json` | Current state of all queues. Persisted to disk after every change |
762
806
  | `.task_history.json` | Last 50 completed tasks (for `/history`) |
763
807
 
@@ -5,6 +5,7 @@ import os from 'os';
5
5
  import path from 'path';
6
6
  import process from 'process';
7
7
  import { createLogger } from './logger.js';
8
+ import { createTaskLogger } from './task-logger.js';
8
9
  import { TelegramPoller, escapeHtml } from './telegram-poller.js';
9
10
  import { WorkQueue } from './work-queue.js';
10
11
  import { TaskRunner } from './task-runner.js';
@@ -16,7 +17,7 @@ import { parseMessage, parseTarget } from './message-parser.js';
16
17
  // ----------------------
17
18
 
18
19
  const CONFIG_PATH = path.join(os.homedir(), '.claude', 'notifier.config.json');
19
- const LOG_PATH = path.join(os.homedir(), '.claude', '.listener.log');
20
+ const DEFAULT_LOG_DIR = path.join(os.homedir(), '.claude');
20
21
 
21
22
  function loadConfig () {
22
23
  try {
@@ -33,7 +34,9 @@ function loadConfig () {
33
34
  // ----------------------
34
35
 
35
36
  const config = loadConfig();
36
- const logger = createLogger(LOG_PATH);
37
+ const listenerLogDir = config.listener?.logDir || DEFAULT_LOG_DIR;
38
+ fs.mkdirSync(listenerLogDir, { recursive: true });
39
+ const logger = createLogger(path.join(listenerLogDir, '.cc-n-listener.log'));
37
40
 
38
41
  // Validate required fields
39
42
  const token = process.env.CLAUDE_NOTIFY_TELEGRAM_TOKEN || config.telegramToken || config.telegram?.token;
@@ -61,7 +64,10 @@ const queue = new WorkQueue(
61
64
  listenerConfig.maxQueuePerWorkDir || 10,
62
65
  listenerConfig.maxTotalTasks || 50,
63
66
  );
64
- const runner = new TaskRunner(logger, taskTimeout);
67
+ const taskLogDir = config.listener?.taskLogDir || listenerLogDir;
68
+ fs.mkdirSync(taskLogDir, { recursive: true });
69
+ const taskLogger = createTaskLogger(taskLogDir);
70
+ const runner = new TaskRunner(logger, taskTimeout, taskLogger);
65
71
  const worktreeManager = new WorktreeManager(config, logger);
66
72
 
67
73
  const startTime = Date.now();
@@ -96,24 +102,35 @@ runner.on('complete', async (workDir, task, output) => {
96
102
  const entry = queue.queues[workDir];
97
103
  const label = formatLabel(entry);
98
104
 
99
- // Send result
100
- let text = `✅ [${label}] Done: ${escapeHtml(task.text)}`;
105
+ // Delete the "Running" message
106
+ await poller.deleteMessage(task.runningMessageId);
107
+
108
+ // Build result: try replying to user's original message without duplicating the task text.
109
+ // If reply fails (user deleted their message), resend with task text included.
110
+ const headerShort = `✅ [${label}] Done:`;
111
+ const headerFull = `✅ [${label}] Done: ${escapeHtml(task.text)}`;
112
+ let body = '';
101
113
  if (output) {
102
114
  if (output.length > 20000) {
103
115
  const head = output.slice(0, 2000);
104
116
  const tail = output.slice(-2000);
105
- text += `\n\n<pre>${escapeHtml(head)}\n\n... (${output.length} chars) ...\n\n${escapeHtml(tail)}</pre>`;
106
- // Also send as file
117
+ body = `\n\n<pre>${escapeHtml(head)}\n\n... (${output.length} chars) ...\n\n${escapeHtml(tail)}</pre>`;
107
118
  await poller.sendDocument(
108
119
  Buffer.from(output, 'utf-8'),
109
120
  `result_${task.id}.txt`,
110
121
  `Full output for: ${task.text.slice(0, 100)}`
111
122
  );
112
123
  } else {
113
- text += `\n\n<pre>${escapeHtml(output)}</pre>`;
124
+ body = `\n\n<pre>${escapeHtml(output)}</pre>`;
114
125
  }
115
126
  }
116
- await poller.sendMessage(text, task.telegramMessageId);
127
+
128
+ // Try reply to original message (short header, task text visible in quote)
129
+ const sentId = await poller.sendMessage(headerShort + body, task.telegramMessageId);
130
+ if (!sentId && task.telegramMessageId) {
131
+ // Reply failed — original message was deleted, send without reply but with full task text
132
+ await poller.sendMessage(headerFull + body);
133
+ }
117
134
 
118
135
  // Process next in queue
119
136
  const next = queue.onTaskComplete(workDir, output);
@@ -125,10 +142,14 @@ runner.on('complete', async (workDir, task, output) => {
125
142
  runner.on('error', async (workDir, task, errorMsg) => {
126
143
  const entry = queue.queues[workDir];
127
144
  const label = formatLabel(entry);
128
- await poller.sendMessage(
129
- `❌ [${label}] Error: ${escapeHtml(task.text)}\n\n<pre>${escapeHtml(errorMsg)}</pre>`,
130
- task.telegramMessageId,
131
- );
145
+
146
+ await poller.deleteMessage(task.runningMessageId);
147
+
148
+ const body = `\n\n<pre>${escapeHtml(errorMsg)}</pre>`;
149
+ const sentId = await poller.sendMessage(`❌ [${label}] Error:${body}`, task.telegramMessageId);
150
+ if (!sentId && task.telegramMessageId) {
151
+ await poller.sendMessage(`❌ [${label}] Error: ${escapeHtml(task.text)}${body}`);
152
+ }
132
153
 
133
154
  const next = queue.onTaskComplete(workDir, `ERROR: ${errorMsg}`);
134
155
  if (next) {
@@ -139,10 +160,16 @@ runner.on('error', async (workDir, task, errorMsg) => {
139
160
  runner.on('timeout', async (workDir, task) => {
140
161
  const entry = queue.queues[workDir];
141
162
  const label = formatLabel(entry);
142
- await poller.sendMessage(
143
- `⏰ [${label}] Task forcefully stopped — timeout exceeded (${Math.round(taskTimeout / 60000)} min): ${escapeHtml(task.text)}`,
144
- task.telegramMessageId,
145
- );
163
+ const timeoutMin = Math.round(taskTimeout / 60000);
164
+
165
+ await poller.deleteMessage(task.runningMessageId);
166
+
167
+ const headerShort = `⏰ [${label}] Task forcefully stopped — timeout exceeded (${timeoutMin} min)`;
168
+ const headerFull = `${headerShort}: ${escapeHtml(task.text)}`;
169
+ const sentId = await poller.sendMessage(headerShort, task.telegramMessageId);
170
+ if (!sentId && task.telegramMessageId) {
171
+ await poller.sendMessage(headerFull);
172
+ }
146
173
 
147
174
  const next = queue.onTaskComplete(workDir, 'TIMEOUT');
148
175
  if (next) {
@@ -164,10 +191,11 @@ function formatLabel (entry) {
164
191
  return `@${entry.project}`;
165
192
  }
166
193
 
167
- function startTask (workDir, task) {
194
+ async function startTask (workDir, task) {
168
195
  const entry = queue.queues[workDir];
169
196
  const label = formatLabel(entry);
170
- poller.sendMessage(`⏳ [${label}] Running: ${escapeHtml(task.text)}`, task.telegramMessageId);
197
+ const runningMsgId = await poller.sendMessage(`⏳ [${label}] Running: ${escapeHtml(task.text)}`, task.telegramMessageId);
198
+ task.runningMessageId = runningMsgId;
171
199
  try {
172
200
  const started = runner.run(workDir, task);
173
201
  queue.markStarted(workDir, started.pid);
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+
6
+ const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB
7
+
8
+ function sanitize (str) {
9
+ return str.replace(/[/\\:*?"<>|\s]+/g, '_').replace(/^_+|_+$/g, '');
10
+ }
11
+
12
+ function rotateIfNeeded (filePath) {
13
+ try {
14
+ const stat = fs.statSync(filePath);
15
+ if (stat.size > MAX_LOG_SIZE) {
16
+ const backup = filePath + '.old';
17
+ if (fs.existsSync(backup)) {
18
+ fs.unlinkSync(backup);
19
+ }
20
+ fs.renameSync(filePath, backup);
21
+ }
22
+ } catch {
23
+ // file doesn't exist yet
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Creates a task logger that writes Q&A logs for Claude tasks.
29
+ * Each project/branch combo gets its own log file.
30
+ *
31
+ * @param {string} logDir - Directory for task log files
32
+ */
33
+ export function createTaskLogger (logDir) {
34
+ fs.mkdirSync(logDir, { recursive: true });
35
+
36
+ function getLogPath (project, branch) {
37
+ const parts = [project];
38
+ if (branch && branch !== 'main' && branch !== 'master') {
39
+ parts.push(branch);
40
+ }
41
+ const name = `.cc-n-task-${sanitize(parts.join('_'))}.log`;
42
+ return path.join(logDir, name);
43
+ }
44
+
45
+ return {
46
+ logQuestion (project, branch, workDir, taskText) {
47
+ const logPath = getLogPath(project, branch);
48
+ rotateIfNeeded(logPath);
49
+ const ts = new Date().toISOString();
50
+ const entry = `\n${'='.repeat(80)}\n`
51
+ + `[${ts}] QUESTION\n`
52
+ + `Project: @${project}${branch && branch !== 'main' && branch !== 'master' ? '/' + branch : ''}\n`
53
+ + `WorkDir: ${workDir}\n`
54
+ + `Task: ${taskText}\n`;
55
+ fs.appendFileSync(logPath, entry);
56
+ },
57
+
58
+ logAnswer (project, branch, output, exitCode) {
59
+ const logPath = getLogPath(project, branch);
60
+ rotateIfNeeded(logPath);
61
+ const ts = new Date().toISOString();
62
+ const status = exitCode === 0 ? 'OK' : `ERROR (code ${exitCode})`;
63
+ const entry = `[${ts}] ANSWER [${status}]\n`
64
+ + `${output || '(no output)'}\n`;
65
+ fs.appendFileSync(logPath, entry);
66
+ },
67
+ };
68
+ }
@@ -9,10 +9,11 @@ const DEFAULT_TIMEOUT = 600_000; // 10 minutes
9
9
  * Runs claude CLI tasks and emits events on completion.
10
10
  */
11
11
  export class TaskRunner extends EventEmitter {
12
- constructor (logger, timeout) {
12
+ constructor (logger, timeout, taskLogger) {
13
13
  super();
14
14
  this.logger = logger;
15
15
  this.timeout = timeout || DEFAULT_TIMEOUT;
16
+ this.taskLogger = taskLogger || null;
16
17
  this.activeProcesses = new Map(); // workDir → { child, timer, task }
17
18
  }
18
19
 
@@ -28,6 +29,9 @@ export class TaskRunner extends EventEmitter {
28
29
  }
29
30
 
30
31
  this.logger.info(`Running task "${task.text}" in ${workDir}`);
32
+ if (this.taskLogger) {
33
+ this.taskLogger.logQuestion(task.project || 'unknown', task.branch || 'main', workDir, task.text);
34
+ }
31
35
 
32
36
  const args = ['-p', task.text, '--output-format', 'text'];
33
37
  const child = spawn('claude', args, {
@@ -66,10 +70,16 @@ export class TaskRunner extends EventEmitter {
66
70
 
67
71
  if (code === 0) {
68
72
  this.logger.info(`Task "${task.id}" completed in ${workDir}`);
73
+ if (this.taskLogger) {
74
+ this.taskLogger.logAnswer(task.project || 'unknown', task.branch || 'main', stdout.trim(), 0);
75
+ }
69
76
  this.emit('complete', workDir, task, stdout.trim());
70
77
  } else {
71
78
  const errorMsg = stderr.trim() || `Process exited with code ${code}`;
72
79
  this.logger.error(`Task "${task.id}" failed in ${workDir}: ${errorMsg}`);
80
+ if (this.taskLogger) {
81
+ this.taskLogger.logAnswer(task.project || 'unknown', task.branch || 'main', errorMsg, code);
82
+ }
73
83
  this.emit('error', workDir, task, errorMsg);
74
84
  }
75
85
  });
@@ -50,6 +50,7 @@ export class TelegramPoller {
50
50
 
51
51
  async sendMessage (text, replyToMessageId) {
52
52
  const chunks = splitMessage(text);
53
+ let firstMessageId = null;
53
54
  for (const chunk of chunks) {
54
55
  try {
55
56
  const body = {
@@ -68,7 +69,7 @@ export class TelegramPoller {
68
69
  const data = await res.json();
69
70
  if (!data.ok) {
70
71
  // Retry without HTML parse mode
71
- await fetch(`${this.baseUrl}/sendMessage`, {
72
+ const res2 = await fetch(`${this.baseUrl}/sendMessage`, {
72
73
  method: 'POST',
73
74
  headers: { 'Content-Type': 'application/json' },
74
75
  body: JSON.stringify({
@@ -77,11 +78,36 @@ export class TelegramPoller {
77
78
  ...(replyToMessageId ? { reply_to_message_id: replyToMessageId } : {}),
78
79
  }),
79
80
  });
81
+ const data2 = await res2.json();
82
+ if (data2.ok && !firstMessageId) {
83
+ firstMessageId = data2.result.message_id;
84
+ }
85
+ } else if (!firstMessageId) {
86
+ firstMessageId = data.result.message_id;
80
87
  }
81
88
  } catch (err) {
82
89
  this.logger.error(`sendMessage error: ${err.message}`);
83
90
  }
84
91
  }
92
+ return firstMessageId;
93
+ }
94
+
95
+ async deleteMessage (messageId) {
96
+ if (!messageId) {
97
+ return;
98
+ }
99
+ try {
100
+ await fetch(`${this.baseUrl}/deleteMessage`, {
101
+ method: 'POST',
102
+ headers: { 'Content-Type': 'application/json' },
103
+ body: JSON.stringify({
104
+ chat_id: this.chatId,
105
+ message_id: messageId,
106
+ }),
107
+ });
108
+ } catch (err) {
109
+ this.logger.error(`deleteMessage error: ${err.message}`);
110
+ }
85
111
  }
86
112
 
87
113
  async sendDocument (buffer, filename, caption) {
@@ -54,6 +54,8 @@ export class WorkQueue {
54
54
  const task = {
55
55
  id: generateId(),
56
56
  text,
57
+ project,
58
+ branch: branch || 'main',
57
59
  telegramMessageId,
58
60
  addedAt: new Date().toISOString(),
59
61
  };
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.0.78",
4
+ "version": "1.0.82",
5
5
  "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
6
  "type": "module",
7
7
  "engines": {
@@ -21,7 +21,8 @@
21
21
  "claude-notify-install": "bin/install.js",
22
22
  "claude-notify-uninstall": "bin/uninstall.js",
23
23
  "claude-notifier": "notifier/notifier.js",
24
- "claude-notify-listener": "bin/listener-cli.js"
24
+ "claude-notify-listener": "bin/listener-cli.js",
25
+ "claude-notify-register": "bin/register-cli.js"
25
26
  },
26
27
  "scripts": {
27
28
  "lint": "eslint .",