claude-notification-plugin 1.1.83 → 1.1.86

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.83",
3
+ "version": "1.1.86",
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
@@ -265,6 +265,27 @@ Projects are referenced with the `&` prefix (e.g. `&api`, `&api/branch`).
265
265
  | `/menu` | Show help with inline buttons |
266
266
  | `/help` | Show help with inline buttons |
267
267
 
268
+ #### Raw REPL commands (`%cmd`)
269
+
270
+ To forward a slash-command straight into the live Claude Code REPL (instead of
271
+ letting the listener intercept it), prefix it with `%`:
272
+
273
+ | Telegram message | What gets sent to Claude PTY |
274
+ |-------------------------|----------------------------------------------|
275
+ | `%clear` | `/clear` in the default project |
276
+ | `&api %compact` | `/compact` in the `&api` main worktree |
277
+ | `&api/feature %cost` | `/cost` in the `&api/feature` worktree |
278
+ | `%%foo` | literal task starting with `%foo` (escape) |
279
+
280
+ Useful for `/clear`, `/compact`, `/cost`, `/model`, `/status` etc. — commands
281
+ that act on the running Claude session without going through the listener's
282
+ command router. The PTY session is **kept alive**, so subsequent tasks
283
+ continue in the same conversation (with cleared context after `/clear`).
284
+
285
+ Because REPL commands usually don't produce a `Stop` hook event, raw tasks
286
+ complete after ~8 s of PTY inactivity; the last chunk of output is included
287
+ in the confirmation reply.
288
+
268
289
  #### Registering projects on the fly (`/addproject` + `/seen`)
269
290
 
270
291
  Whenever the notifier fires for a folder, it records the absolute path,
@@ -31,6 +31,7 @@ function getLogFile () {
31
31
  return path.join(CLAUDE_DIR, LISTENER_LOG_FILENAME);
32
32
  }
33
33
  }
34
+
34
35
  const LISTENER_SCRIPT = path.join(__dirname, '..', 'listener', 'listener.js');
35
36
 
36
37
  const command = process.argv[2];
@@ -69,15 +70,20 @@ Commands:
69
70
  }
70
71
 
71
72
  async function startDaemon () {
72
- // Check if already running
73
+ // Replace any prior instance — both stale PID-files and live processes.
74
+ // Telegram allows exactly one getUpdates consumer per token, so a zombie
75
+ // listener (terminal closed, taskkill failed mid-stop, etc.) holds the
76
+ // long-poll slot and blocks the new one with 409 Conflict. Active SIGTERM
77
+ // makes `start` idempotent: user typed `start` → listener is running.
73
78
  const existingPid = readPid();
74
- if (existingPid && isProcessAlive(existingPid)) {
75
- console.log(`Listener is already running (PID: ${existingPid})`);
76
- process.exit(1);
77
- }
78
-
79
- // Clean stale PID file
80
79
  if (existingPid) {
80
+ if (isProcessAlive(existingPid)) {
81
+ console.log(`Replacing prior listener (PID: ${existingPid})...`);
82
+ if (!terminatePid(existingPid)) {
83
+ console.error(`Failed to stop prior listener (PID: ${existingPid}). Exiting.`);
84
+ process.exit(1);
85
+ }
86
+ }
81
87
  try {
82
88
  fs.unlinkSync(PID_PATH);
83
89
  } catch {
@@ -167,7 +173,20 @@ function stopDaemon () {
167
173
  }
168
174
 
169
175
  console.log(`Stopping listener (PID: ${pid})...`);
176
+ if (terminatePid(pid)) {
177
+ cleanPid();
178
+ console.log('Listener stopped');
179
+ } else {
180
+ console.error(`Failed to stop listener (PID: ${pid})`);
181
+ }
182
+ }
170
183
 
184
+ /**
185
+ * Best-effort process termination. Returns true if the target is gone after
186
+ * the call, false otherwise. Used both by `stop` and by `start` (to evict a
187
+ * stale prior instance holding the Telegram long-poll slot).
188
+ */
189
+ function terminatePid (pid) {
171
190
  try {
172
191
  if (process.platform === 'win32') {
173
192
  execSync(`taskkill /PID ${pid} /T /F`, {
@@ -176,7 +195,6 @@ function stopDaemon () {
176
195
  });
177
196
  } else {
178
197
  process.kill(pid, 'SIGTERM');
179
- // Wait for graceful shutdown
180
198
  let tries = 10;
181
199
  while (tries-- > 0 && isProcessAlive(pid)) {
182
200
  execSync('sleep 0.5', { stdio: 'ignore' });
@@ -186,11 +204,9 @@ function stopDaemon () {
186
204
  }
187
205
  }
188
206
  } catch {
189
- // Process may already be dead
207
+ // Already dead, or no permission — fall through to the alive check.
190
208
  }
191
-
192
- cleanPid();
193
- console.log('Listener stopped');
209
+ return !isProcessAlive(pid);
194
210
  }
195
211
 
196
212
  async function showStatus () {
package/commit-sha CHANGED
@@ -1 +1 @@
1
- 0567928a0b6df6aaefebbb8e507bc8fef8ae5942
1
+ 84cbba88a79b9dd591d9a047174b93d28e04caa6
@@ -201,6 +201,29 @@ runner.on('complete', async (workDir, task, result) => {
201
201
  // Delete the "Running" message
202
202
  await poller.deleteMessage(task.runningMessageId);
203
203
 
204
+ const output = result.text || '';
205
+
206
+ if (task.raw) {
207
+ // Raw slash-command: compact "sent" confirmation, don't bump session counter.
208
+ // `/clear` wipes Claude's context — reset our counters too.
209
+ const normalized = (task.text || '').trim().toLowerCase();
210
+ if (normalized === '/clear') {
211
+ sessions.delete(workDir);
212
+ }
213
+ const headerShort = `📨 <code>${label}</code> sent <code>${escapeHtml(task.text)}</code>`;
214
+ const tail = output ? output.slice(-1500) : '';
215
+ const body = tail ? `\n\n<pre>${escapeHtml(tail)}</pre>` : '';
216
+ const sentId = await poller.sendMessage(headerShort + body, task.telegramMessageId);
217
+ if (!sentId && task.telegramMessageId) {
218
+ await poller.sendMessage(headerShort + body);
219
+ }
220
+ const next = queue.onTaskComplete(workDir, output);
221
+ if (next) {
222
+ startTask(workDir, next);
223
+ }
224
+ return;
225
+ }
226
+
204
227
  // Update session tracking
205
228
  const session = sessions.get(workDir) || { taskCount: 0 };
206
229
  session.taskCount++;
@@ -231,7 +254,6 @@ runner.on('complete', async (workDir, task, result) => {
231
254
  const sessionIcon = task.continueSession ? '🔄' : '🆕';
232
255
 
233
256
  // Build result
234
- const output = result.text || '';
235
257
  const headerShort = `✅ ${sessionIcon} <code>${label}</code>${sessionInfo}`;
236
258
  const headerFull = `${headerShort}\n\n${escapeHtml(task.text)}`;
237
259
  let body = '';
@@ -429,6 +451,29 @@ async function startTask (workDir, task) {
429
451
  const continueSession = shouldContinueSession(workDir);
430
452
  const session = sessions.get(workDir);
431
453
 
454
+ // Raw slash-commands get a compact running message and skip the live console.
455
+ if (task.raw) {
456
+ const runningRaw = `📨 <code>${label}</code> sending <code>${escapeHtml(task.text)}</code>…`;
457
+ let runningMsgId = null;
458
+ if (task.telegramMessageId) {
459
+ runningMsgId = await poller.sendMessage(runningRaw, task.telegramMessageId);
460
+ }
461
+ if (!runningMsgId) {
462
+ runningMsgId = await poller.sendMessage(runningRaw);
463
+ }
464
+ task.runningMessageId = runningMsgId;
465
+ const claudeArgs = getClaudeArgs(entry?.project);
466
+ try {
467
+ runner.run(workDir, task, claudeArgs, continueSession);
468
+ queue.markStarted(workDir, task.pid || 0);
469
+ } catch (err) {
470
+ logger.error(`Failed to start raw task: ${err.message}`);
471
+ poller.sendMessage(`❌ <code>${label}</code>\nFailed to start: ${escapeHtml(err.message)}`);
472
+ queue.onTaskComplete(workDir, `START_ERROR: ${err.message}`);
473
+ }
474
+ return;
475
+ }
476
+
432
477
  // Build running message with session info
433
478
  let sessionTag = '';
434
479
  if (continueSession && session) {
@@ -1239,6 +1284,11 @@ function handleHelp () {
1239
1284
  <code>&amp;project/branch task</code> — worktree
1240
1285
  <code>task</code> — default project
1241
1286
 
1287
+ <b>Raw REPL commands (forward to live Claude session):</b>
1288
+ <code>%clear</code> — send <code>/clear</code> into the running Claude PTY
1289
+ <code>&amp;project %compact</code> — same, targeting project
1290
+ <code>%%foo</code> — literal task starting with <code>%foo</code> (escape)
1291
+
1242
1292
  <b>Session:</b>
1243
1293
  🆕 = new session, 🔄 = continuing session
1244
1294
  ctx N% = context window usage`,
@@ -1286,6 +1336,7 @@ async function handleTask (parsed, telegramMessageId) {
1286
1336
  parsed.branch || 'main',
1287
1337
  parsed.text,
1288
1338
  telegramMessageId,
1339
+ !!parsed.raw,
1289
1340
  );
1290
1341
 
1291
1342
  if (result.error) {
@@ -9,7 +9,12 @@
9
9
  * &project text → { type: 'task', project, branch: null, text }
10
10
  * text → { type: 'task', project: <defaultProject>, branch: null, text }
11
11
  *
12
- * Any /word is treated as a command (known or unknown).
12
+ * Raw slash-commands forwarded to the live Claude REPL:
13
+ * %cmd [args] → task with raw:true, text='/cmd [args]'
14
+ * &project %cmd [args] → same, targeting the project's PTY
15
+ * %%foo → literal task starting with "%foo" (escape)
16
+ *
17
+ * Any /word is treated as a listener command (known or unknown).
13
18
  * Project designation uses & prefix: &project or &project/branch.
14
19
  *
15
20
  * @param {string} text - The message text.
@@ -41,7 +46,8 @@ export function parseMessage (text, defaultProject) {
41
46
  const projectMatch = trimmed.match(/^&(\S+)\s+([\s\S]+)$/);
42
47
  if (projectMatch) {
43
48
  const target = projectMatch[1];
44
- const taskText = projectMatch[2].trim();
49
+ const rawText = projectMatch[2].trim();
50
+ const { text: taskText, raw } = parseRawPrefix(rawText);
45
51
 
46
52
  const slashIndex = target.indexOf('/');
47
53
  if (slashIndex > 0) {
@@ -50,6 +56,7 @@ export function parseMessage (text, defaultProject) {
50
56
  project: target.substring(0, slashIndex),
51
57
  branch: target.substring(slashIndex + 1),
52
58
  text: taskText,
59
+ raw,
53
60
  };
54
61
  }
55
62
  return {
@@ -57,19 +64,38 @@ export function parseMessage (text, defaultProject) {
57
64
  project: target,
58
65
  branch: null,
59
66
  text: taskText,
67
+ raw,
60
68
  };
61
69
  }
62
70
  }
63
71
 
64
72
  // Plain text → default project
73
+ const { text: taskText, raw } = parseRawPrefix(trimmed);
65
74
  return {
66
75
  type: 'task',
67
76
  project: defaultProject || 'default',
68
77
  branch: null,
69
- text: trimmed,
78
+ text: taskText,
79
+ raw,
70
80
  };
71
81
  }
72
82
 
83
+ /**
84
+ * Detect `%cmd` / `%%literal` prefix.
85
+ * - `%%foo` → plain task "%foo"
86
+ * - `%foo` → raw task "/foo" (forwarded to PTY verbatim)
87
+ * - anything else → unchanged plain task
88
+ */
89
+ function parseRawPrefix (text) {
90
+ if (text.startsWith('%%')) {
91
+ return { text: text.slice(1), raw: false };
92
+ }
93
+ if (text.startsWith('%')) {
94
+ return { text: '/' + text.slice(1), raw: true };
95
+ }
96
+ return { text, raw: false };
97
+ }
98
+
73
99
  /**
74
100
  * Parse &project or &project/branch from command args.
75
101
  * Returns { project, branch, rest } or null.
@@ -4,8 +4,12 @@ import fs from 'fs';
4
4
  import path from 'path';
5
5
  import { EventEmitter } from 'events';
6
6
  import { PTY_SIGNAL_DIR } from '../bin/constants.js';
7
+ import { cleanPtyOutput } from './telegram-poller.js';
7
8
 
8
9
  const DEFAULT_TIMEOUT = 600_000; // 10 minutes
10
+ // Built-in slash-commands (forwarded via %cmd) rarely emit a Stop hook event,
11
+ // so we fall back to "done" after this much buffer inactivity.
12
+ const RAW_INACTIVITY_MS = 8_000;
9
13
 
10
14
  /**
11
15
  * PTY-based runner for Claude Code.
@@ -318,8 +322,11 @@ export class PtyRunner extends EventEmitter {
318
322
  session._buffer = '';
319
323
  this._openPtyLog(session, task);
320
324
 
321
- // Set up marker wait + inactivity timeout
322
- const markerPromise = this._waitForMarker(pendingId, this.timeout, session);
325
+ // Set up marker wait + inactivity timeout. Raw slash-commands use a much
326
+ // shorter window because they typically don't trigger an agent turn and
327
+ // therefore never produce a Stop signal.
328
+ const inactivityMs = task.raw ? RAW_INACTIVITY_MS : this.timeout;
329
+ const markerPromise = this._waitForMarker(pendingId, inactivityMs, session);
323
330
 
324
331
  // Send the task text to the PTY.
325
332
  // Bracketed paste mode (\x1b[200~...\x1b[201~) causes Claude to hang in ConPTY,
@@ -379,6 +386,29 @@ export class PtyRunner extends EventEmitter {
379
386
  session.currentTask = null;
380
387
 
381
388
  if (err.message === 'Marker timeout') {
389
+ if (task.raw) {
390
+ // Slash commands (e.g. /clear, /cost) usually don't emit a Stop hook.
391
+ // Treat inactivity as successful completion and keep the PTY alive.
392
+ const cleaned = cleanPtyOutput(session._buffer || '').trim();
393
+ const tail = cleaned.length > 2000 ? cleaned.slice(-2000) : cleaned;
394
+ const result = {
395
+ text: tail,
396
+ sessionId: session.sessionId || null,
397
+ cost: 0,
398
+ numTurns: 0,
399
+ durationMs: 0,
400
+ contextWindow: 0,
401
+ totalTokens: 0,
402
+ isError: false,
403
+ raw: true,
404
+ };
405
+ this.logger.info(`PTY raw command finished (no Stop signal) in ${workDir}`);
406
+ if (this.taskLogger) {
407
+ this.taskLogger.logAnswer(task.project || 'unknown', task.branch || 'main', tail, 0);
408
+ }
409
+ this.emit('complete', workDir, task, result);
410
+ return;
411
+ }
382
412
  this.logger.warn(`PTY task timed out in ${workDir}`);
383
413
  this._destroyPty(workDir);
384
414
  this.emit('timeout', workDir, task);
@@ -2,6 +2,10 @@
2
2
 
3
3
  const POLL_TIMEOUT = 30; // seconds
4
4
  const MAX_MESSAGE_LENGTH = 4096;
5
+ // Telegram allows exactly one getUpdates consumer per token. If we keep seeing
6
+ // 409 it means another instance is polling — exit so the user notices instead
7
+ // of looping silently.
8
+ const MAX_CONSECUTIVE_409 = 8;
5
9
 
6
10
  export class TelegramPoller {
7
11
  constructor (token, chatId, logger) {
@@ -12,6 +16,7 @@ export class TelegramPoller {
12
16
  this.offset = 0;
13
17
  this._errorBackoff = 0; // current backoff in ms (0 = no backoff)
14
18
  this._consecutiveErrors = 0;
19
+ this._consecutive409 = 0;
15
20
  }
16
21
 
17
22
  async flush () {
@@ -38,7 +43,23 @@ export class TelegramPoller {
38
43
  const res = await fetch(url, { signal: AbortSignal.timeout((POLL_TIMEOUT + 10) * 1000) });
39
44
  const data = await res.json();
40
45
  if (!data.ok) {
41
- this.logger.error(`getUpdates failed: ${JSON.stringify(data)}`);
46
+ if (data.error_code === 409) {
47
+ this._consecutive409++;
48
+ if (this._consecutive409 >= MAX_CONSECUTIVE_409) {
49
+ this.logger.error(
50
+ `409 Conflict persists after ${this._consecutive409} attempts — `
51
+ + 'another listener is holding the bot token. Exiting.',
52
+ );
53
+ console.error(
54
+ `409 Conflict persists after ${this._consecutive409} attempts — `
55
+ + 'another listener (or stray getUpdates consumer) is holding the bot token. Exiting.',
56
+ );
57
+ process.exit(2);
58
+ }
59
+ this.logger.warn(`getUpdates 409 Conflict (attempt ${this._consecutive409}/${MAX_CONSECUTIVE_409})`);
60
+ } else {
61
+ this.logger.error(`getUpdates failed: ${JSON.stringify(data)}`);
62
+ }
42
63
  this._applyBackoff();
43
64
  return [];
44
65
  }
@@ -47,6 +68,7 @@ export class TelegramPoller {
47
68
  this.logger.info('getUpdates recovered after errors');
48
69
  }
49
70
  this._consecutiveErrors = 0;
71
+ this._consecutive409 = 0;
50
72
  this._errorBackoff = 0;
51
73
 
52
74
  const messages = [];
@@ -268,7 +290,9 @@ function splitMessage (text) {
268
290
  return [`${head}\n\n<i>... (truncated ${text.length} chars) ...</i>\n\n${tail}`];
269
291
  }
270
292
 
271
- // Split into chunks preserving line boundaries
293
+ // Split into chunks with tiered preference: paragraph → line → space → hard.
294
+ // Each tier is accepted only if it falls past the midpoint, otherwise
295
+ // we'd produce a tiny chunk followed by a huge one.
272
296
  const chunks = [];
273
297
  let remaining = text;
274
298
  while (remaining.length > 0) {
@@ -276,12 +300,22 @@ function splitMessage (text) {
276
300
  chunks.push(remaining);
277
301
  break;
278
302
  }
279
- let splitAt = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
280
- if (splitAt <= 0) {
303
+ const para = remaining.lastIndexOf('\n\n', MAX_MESSAGE_LENGTH);
304
+ const line = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
305
+ const space = remaining.lastIndexOf(' ', MAX_MESSAGE_LENGTH);
306
+ const half = MAX_MESSAGE_LENGTH / 2;
307
+ let splitAt;
308
+ if (para > half) {
309
+ splitAt = para;
310
+ } else if (line > half) {
311
+ splitAt = line;
312
+ } else if (space > 0) {
313
+ splitAt = space;
314
+ } else {
281
315
  splitAt = MAX_MESSAGE_LENGTH;
282
316
  }
283
317
  chunks.push(remaining.slice(0, splitAt));
284
- remaining = remaining.slice(splitAt + 1);
318
+ remaining = remaining.slice(splitAt).replace(/^\n+/, '');
285
319
  }
286
320
  return chunks;
287
321
  }
@@ -30,7 +30,7 @@ export class WorkQueue {
30
30
  * Enqueue a task for a workDir. Returns the task object.
31
31
  * If no active task, marks it as ready to run immediately.
32
32
  */
33
- enqueue (workDir, project, branch, text, telegramMessageId) {
33
+ enqueue (workDir, project, branch, text, telegramMessageId, raw = false) {
34
34
  if (!this.queues[workDir]) {
35
35
  this.queues[workDir] = {
36
36
  project,
@@ -57,6 +57,7 @@ export class WorkQueue {
57
57
  project,
58
58
  branch: branch || 'main',
59
59
  telegramMessageId,
60
+ raw: !!raw,
60
61
  addedAt: new Date().toISOString(),
61
62
  };
62
63
 
@@ -262,12 +263,21 @@ export class WorkQueue {
262
263
  }
263
264
 
264
265
  _load () {
266
+ if (!fs.existsSync(QUEUE_FILE)) {
267
+ return;
268
+ }
265
269
  try {
266
- if (fs.existsSync(QUEUE_FILE)) {
267
- this.queues = JSON.parse(fs.readFileSync(QUEUE_FILE, 'utf-8'));
268
- }
270
+ this.queues = JSON.parse(fs.readFileSync(QUEUE_FILE, 'utf-8'));
269
271
  } catch (err) {
270
- this.logger.error(`Failed to load queue file: ${err.message}`);
272
+ // Move aside corrupt file (truncated by crash mid-write, ENOSPC, etc.)
273
+ // so the next start succeeds instead of looping on a parse error.
274
+ const corrupt = `${QUEUE_FILE}.corrupt-${Date.now()}`;
275
+ try {
276
+ fs.renameSync(QUEUE_FILE, corrupt);
277
+ this.logger.error(`Queue file corrupt, moved to ${corrupt}: ${err.message}`);
278
+ } catch (renameErr) {
279
+ this.logger.error(`Queue file corrupt and rename failed: ${err.message} / ${renameErr.message}`);
280
+ }
271
281
  this.queues = {};
272
282
  }
273
283
  }
@@ -276,7 +286,11 @@ export class WorkQueue {
276
286
  try {
277
287
  const dir = path.dirname(QUEUE_FILE);
278
288
  fs.mkdirSync(dir, { recursive: true });
279
- fs.writeFileSync(QUEUE_FILE, JSON.stringify(this.queues, null, 2));
289
+ // Atomic write: tmp + rename. Crash mid-write leaves the tmp orphan,
290
+ // not a half-written QUEUE_FILE.
291
+ const tmp = `${QUEUE_FILE}.${process.pid}.tmp`;
292
+ fs.writeFileSync(tmp, JSON.stringify(this.queues, null, 2));
293
+ fs.renameSync(tmp, QUEUE_FILE);
280
294
  } catch (err) {
281
295
  this.logger.error(`Failed to save queue file: ${err.message}`);
282
296
  }
@@ -289,20 +303,29 @@ export class WorkQueue {
289
303
  history.shift();
290
304
  }
291
305
  try {
292
- fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
306
+ const tmp = `${HISTORY_FILE}.${process.pid}.tmp`;
307
+ fs.writeFileSync(tmp, JSON.stringify(history, null, 2));
308
+ fs.renameSync(tmp, HISTORY_FILE);
293
309
  } catch {
294
310
  // ignore
295
311
  }
296
312
  }
297
313
 
298
314
  _loadHistory () {
315
+ if (!fs.existsSync(HISTORY_FILE)) {
316
+ return [];
317
+ }
299
318
  try {
300
- if (fs.existsSync(HISTORY_FILE)) {
301
- return JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'));
319
+ return JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'));
320
+ } catch (err) {
321
+ const corrupt = `${HISTORY_FILE}.corrupt-${Date.now()}`;
322
+ try {
323
+ fs.renameSync(HISTORY_FILE, corrupt);
324
+ this.logger.error(`History file corrupt, moved to ${corrupt}: ${err.message}`);
325
+ } catch {
326
+ // ignore
302
327
  }
303
- } catch {
304
- // ignore
328
+ return [];
305
329
  }
306
- return [];
307
330
  }
308
331
  }
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.83",
4
+ "version": "1.1.86",
5
5
  "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
6
  "type": "module",
7
7
  "engines": {