claude-notification-plugin 1.1.81 → 1.1.84

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.81",
3
+ "version": "1.1.84",
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,
package/commit-sha CHANGED
@@ -1 +1 @@
1
- 5028775e89c41e96abd523526de5ff59ab1bb1e7
1
+ 642d44de740e6e85d391634789966980a389d893
@@ -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);
@@ -406,9 +436,10 @@ export class PtyRunner extends EventEmitter {
406
436
  // Filter out pipe-mode-specific args
407
437
  const args = claudeArgs.filter(a => a !== '-p' && a !== '--output-format' && a !== 'json');
408
438
 
409
- // Ensure --permission-mode is set (default: auto) to prevent interactive permission prompts
410
- if (!args.includes('--permission-mode')) {
411
- args.push('--permission-mode', 'auto');
439
+ // Ensure --permission-mode is set to prevent interactive permission prompts.
440
+ // Use bypassPermissions as default — "auto" is not available on all plans/providers.
441
+ if (!args.includes('--permission-mode') && !args.includes('--dangerously-skip-permissions')) {
442
+ args.push('--permission-mode', 'bypassPermissions');
412
443
  }
413
444
 
414
445
  // Reduce PTY output noise: disable animations, progress bar, tips
@@ -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
 
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.81",
4
+ "version": "1.1.84",
5
5
  "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
6
  "type": "module",
7
7
  "engines": {