claude-notification-plugin 1.1.101 → 1.1.104

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,20 +1,20 @@
1
- {
2
- "name": "claude-notification-plugin",
3
- "version": "1.1.101",
4
- "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
5
- "author": {
6
- "name": "Viacheslav Makarov",
7
- "email": "npmjs@bazilio.ru"
8
- },
9
- "homepage": "https://github.com/Bazilio-san/claude-notification-plugin#readme",
10
- "repository": "https://github.com/Bazilio-san/claude-notification-plugin",
11
- "license": "MIT",
12
- "keywords": [
13
- "notification",
14
- "telegram",
15
- "windows",
16
- "sound",
17
- "voice",
18
- "hooks"
19
- ]
20
- }
1
+ {
2
+ "name": "claude-notification-plugin",
3
+ "version": "1.1.104",
4
+ "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
5
+ "author": {
6
+ "name": "Viacheslav Makarov",
7
+ "email": "npmjs@bazilio.ru"
8
+ },
9
+ "homepage": "https://github.com/Bazilio-san/claude-notification-plugin#readme",
10
+ "repository": "https://github.com/Bazilio-san/claude-notification-plugin",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "notification",
14
+ "telegram",
15
+ "windows",
16
+ "sound",
17
+ "voice",
18
+ "hooks"
19
+ ]
20
+ }
package/README.md CHANGED
@@ -329,7 +329,7 @@ the same path cannot be registered twice under different aliases.
329
329
  | `projects` | (required) | Map of projects: `alias → { path }` |
330
330
  | `claudeArgs` | `[]` | Extra CLI args for Claude (e.g. `["--permission-mode", "auto"]`) |
331
331
  | `continueSession` | `true` | Keep the PTY alive between Telegram tasks so subsequent tasks reuse the same live Claude session |
332
- | `resumeLastSession` | `true` | When spawning a fresh PTY, add `--continue` so Claude picks up the most recent JSONL in the workDir (bridges laptop CC session Telegram listener) |
332
+ | `resumeLastSession` | `true` | When spawning a fresh PTY, resume the exact session whose ID the daemon captured from SessionStart (stored in `~/.claude/.session_state.json`). Survives listener restart. Falls back to a fresh session if no captured ID is available — does NOT use `--continue` blindly. |
333
333
  | `sessionsListLimit` | `5` | How many most-recent sessions `/sessions` shows |
334
334
  | `sessionWorkingThresholdSec` | `2` | A locked JSONL with mtime ≤ this many seconds is considered actively writing (status `working`, resume disabled) |
335
335
  | `worktreeBaseDir` | `~/.claude/worktrees` | Where auto-created worktrees are stored |
package/commit-sha CHANGED
@@ -1 +1 @@
1
- dd4f19294dff2f485865d9526ff5d9a749e36a8a
1
+ 6fb63d8cf4e4026a2909a47213b4303438712d8f
@@ -25,6 +25,11 @@ import {
25
25
  import { JsonlReader, resolveJsonlPath, resolveJsonlByMtime, cwdToProjectDir } from './jsonl-reader.js';
26
26
  import { listSessions } from './session-list.js';
27
27
  import { findLocking, killPid } from './file-locks.js';
28
+ import {
29
+ getStoredSessionId,
30
+ setStoredSessionId,
31
+ clearStoredSessionId,
32
+ } from './session-state.js';
28
33
 
29
34
  // ----------------------
30
35
  // CRASH PROTECTION
@@ -138,7 +143,25 @@ const taskLogDir = config.listener?.taskLogDir || listenerLogDir;
138
143
  fs.mkdirSync(taskLogDir, { recursive: true });
139
144
  const taskLogger = createTaskLogger(taskLogDir);
140
145
 
141
- const runner = new PtyRunner(logger, taskTimeout, taskLogger, taskLogDir);
146
+ // Pass workDirs with in-flight active tasks so PtyRunner's startup cleanup
147
+ // preserves their signal files instead of nuking the Stop hooks that fired
148
+ // while the daemon was down.
149
+ const activeWorkDirsOnBoot = Object.entries(queue.queues)
150
+ .filter(([, entry]) => entry?.active)
151
+ .map(([workDir]) => workDir);
152
+ const runner = new PtyRunner(logger, taskTimeout, taskLogger, taskLogDir, activeWorkDirsOnBoot);
153
+
154
+ // Capture Claude's real sessionId as soon as SessionStart fires, and persist it.
155
+ // This is what lets the next task (or the daemon after a restart) launch with
156
+ // `claude --resume <sid>` and continue *this exact* session — instead of
157
+ // `--continue` picking whichever JSONL happens to have the freshest mtime.
158
+ runner.on('ready', (workDir) => {
159
+ const sid = runner.getSessionId(workDir);
160
+ if (sid) {
161
+ setStoredSessionId(workDir, sid);
162
+ logger.info(`Captured sessionId for ${workDir}: ${sid}`);
163
+ }
164
+ });
142
165
 
143
166
  const worktreeManager = new WorktreeManager(config, logger);
144
167
 
@@ -211,6 +234,41 @@ async function runWatchdog () {
211
234
  }
212
235
  }
213
236
 
237
+ // 0. Migrate queue entries whose `project` alias no longer matches the config
238
+ // (e.g. user renamed `mail` → `me`). Without this, /status, /cancel, and
239
+ // completion notifications keep referring to the old alias even though the
240
+ // workDir is unchanged.
241
+ {
242
+ let migrated = 0;
243
+ for (const [workDir, entry] of Object.entries(queue.queues)) {
244
+ if (!entry) {
245
+ continue;
246
+ }
247
+ const normalizedWd = normalizeForCompare(workDir);
248
+ for (const [alias, proj] of Object.entries(listenerConfig.projects)) {
249
+ const projPath = typeof proj === 'string' ? proj : proj?.path;
250
+ if (!projPath || normalizeForCompare(projPath) !== normalizedWd) {
251
+ continue;
252
+ }
253
+ if (entry.project !== alias) {
254
+ logger.info(`Migrating queue alias for ${workDir}: ${entry.project} → ${alias}`);
255
+ entry.project = alias;
256
+ if (entry.active) {
257
+ entry.active.project = alias;
258
+ }
259
+ for (const t of entry.queue || []) {
260
+ t.project = alias;
261
+ }
262
+ migrated++;
263
+ }
264
+ break;
265
+ }
266
+ }
267
+ if (migrated > 0) {
268
+ queue._save();
269
+ }
270
+ }
271
+
214
272
  // 1. Initial watchdog sweep on startup. Must finish before orphan recovery,
215
273
  // otherwise orphan recovery sees the stale active (still set) and re-spawns
216
274
  // the killed task while watchdog is still awaiting its Telegram notify.
@@ -268,6 +326,11 @@ async function notifyTaskCompletion (workDir, task, kind, payload = {}) {
268
326
  session.lastContextPct = Math.round((payload.totalTokens / payload.contextWindow) * 100);
269
327
  }
270
328
  sessions.set(workDir, session);
329
+ // Persist the sessionId so the next task — including across a listener
330
+ // restart — can resume this exact session via --resume <sid>.
331
+ if (payload.sessionId) {
332
+ setStoredSessionId(workDir, payload.sessionId);
333
+ }
271
334
 
272
335
  const parts = [];
273
336
  if (task.continueSession) {
@@ -369,19 +432,15 @@ function getClaudeArgs (projectAlias) {
369
432
  return projectArgs.length > 0 ? projectArgs : globalClaudeArgs;
370
433
  }
371
434
 
372
- function hasAnyJsonl (workDir) {
373
- const dirName = cwdToProjectDir(workDir);
374
- const dirPath = path.join(CLAUDE_DIR, 'projects', dirName);
375
- try {
376
- return fs.readdirSync(dirPath).some(f => f.endsWith('.jsonl'));
377
- } catch {
378
- return false;
379
- }
380
- }
381
-
382
435
  // Decide whether to add --continue or --resume <id> to claudeArgs for a fresh PTY.
383
436
  // Priority: explicit caller flag > pending /resume <sid> > /newsession opt-out >
384
- // global resumeLastSession default. If reusing an idle PTY, claudeArgs are ignored anyway.
437
+ // persisted sessionId for this workDir > global resumeLastSession default.
438
+ //
439
+ // The persisted-sessionId branch is what makes "continue across listener
440
+ // restart" deterministic: we resume the exact session whose ID we captured
441
+ // from SessionStart, instead of letting --continue pick the most-recently-
442
+ // modified JSONL — which may belong to a different turn or branch.
443
+ // If reusing an idle PTY, claudeArgs are ignored anyway.
385
444
  function applyResumeArgs (claudeArgs, workDir) {
386
445
  if (claudeArgs.includes('--continue') || claudeArgs.includes('--resume')) {
387
446
  return claudeArgs;
@@ -397,8 +456,21 @@ function applyResumeArgs (claudeArgs, workDir) {
397
456
  freshSessionDirs.delete(workDir);
398
457
  return claudeArgs;
399
458
  }
400
- if (resumeLastSessionEnabled && hasAnyJsonl(workDir)) {
401
- return [...claudeArgs, '--continue'];
459
+ if (resumeLastSessionEnabled) {
460
+ const storedSid = getStoredSessionId(workDir);
461
+ if (storedSid) {
462
+ const dirName = cwdToProjectDir(workDir);
463
+ const jsonlPath = path.join(CLAUDE_DIR, 'projects', dirName, `${storedSid}.jsonl`);
464
+ if (fs.existsSync(jsonlPath)) {
465
+ return [...claudeArgs, '--resume', storedSid];
466
+ }
467
+ // Stale entry — JSONL was deleted/moved. Drop the stored sid so we
468
+ // don't keep retrying a dead session.
469
+ clearStoredSessionId(workDir);
470
+ logger.warn(`Stored sessionId ${storedSid} for ${workDir} has no JSONL — cleared`);
471
+ }
472
+ // No reliable sid → start fresh instead of falling back to the old
473
+ // mtime-based --continue lottery.
402
474
  }
403
475
  return claudeArgs;
404
476
  }
@@ -839,6 +911,7 @@ function handleClear (args) {
839
911
  // Also reset session
840
912
  sessions.delete(workDir);
841
913
  freshSessionDirs.add(workDir);
914
+ clearStoredSessionId(workDir);
842
915
  logger.info(`Session reset for ${workDir} via /clear`);
843
916
 
844
917
  return `🧹 [${escapeHtml(label)}] Queue cleared (${count} tasks), session reset`;
@@ -898,6 +971,7 @@ function handleNewSession (args) {
898
971
 
899
972
  sessions.delete(workDir);
900
973
  freshSessionDirs.add(workDir);
974
+ clearStoredSessionId(workDir);
901
975
  logger.info(`Session reset for ${workDir} via /newsession`);
902
976
 
903
977
  if (session) {
@@ -10,6 +10,13 @@ const DEFAULT_TIMEOUT = 600_000; // 10 minutes
10
10
  // Built-in slash-commands (forwarded via %cmd) rarely emit a Stop hook event,
11
11
  // so we fall back to "done" after this much buffer inactivity.
12
12
  const RAW_INACTIVITY_MS = 8_000;
13
+ // Fallback for tasks where the Stop hook silently doesn't fire (e.g. resumed
14
+ // sessions on Windows ConPTY): if the PTY buffer goes quiet AND its tail shows
15
+ // Claude's idle status bar, treat the task as complete.
16
+ const IDLE_PROMPT_FALLBACK_MS = 60_000;
17
+ // Status-bar marker shown by Claude when it's idle at the prompt. Covers
18
+ // "bypass permissions on", "accept edits on", "default mode" etc.
19
+ const IDLE_PROMPT_RE = /\b(?:bypass permissions on|accept edits on|default mode|plan mode)\b/i;
13
20
 
14
21
  /**
15
22
  * PTY-based runner for Claude Code.
@@ -17,12 +24,19 @@ const RAW_INACTIVITY_MS = 8_000;
17
24
  * receives completion signals via marker files written by the notifier hook.
18
25
  */
19
26
  export class PtyRunner extends EventEmitter {
20
- constructor (logger, timeout, taskLogger, ptyLogDir) {
27
+ constructor (logger, timeout, taskLogger, ptyLogDir, activeWorkDirs) {
21
28
  super();
22
29
  this.logger = logger;
23
30
  this.timeout = timeout || DEFAULT_TIMEOUT;
24
31
  this.taskLogger = taskLogger || null;
25
32
  this.ptyLogDir = ptyLogDir || null;
33
+ // Set of normalized workDir paths whose orphan tasks are about to be
34
+ // re-started by listener startup logic. Their pending signal files (if any)
35
+ // must be preserved through the cleanup phase, otherwise we lose hook
36
+ // events the daemon never saw because it wasn't running yet.
37
+ this._preserveSignalsForWorkDirs = new Set(
38
+ (activeWorkDirs || []).map((wd) => this._normalizePath(wd))
39
+ );
26
40
  // workDir -> { pty, state, currentTask, sessionId, workDir, _pendingId, _buffer, _logStream }
27
41
  this.sessions = new Map();
28
42
  this.pendingMarkers = new Map(); // pendingId -> resolve callback
@@ -50,17 +64,36 @@ export class PtyRunner extends EventEmitter {
50
64
  // ignore
51
65
  }
52
66
 
53
- // Clean up stale marker files on startup
67
+ // Clean up stale marker files on startup, but preserve any whose `cwd`
68
+ // matches a workDir with an in-flight orphan task — those signals were
69
+ // written by Claude while the daemon was down and are the only way to
70
+ // notice that the task already finished.
54
71
  try {
55
72
  const files = fs.readdirSync(PTY_SIGNAL_DIR);
56
73
  for (const f of files) {
57
- if (f.endsWith('.json')) {
74
+ if (!f.endsWith('.json')) {
75
+ continue;
76
+ }
77
+ const filePath = path.join(PTY_SIGNAL_DIR, f);
78
+ let preserve = false;
79
+ if (this._preserveSignalsForWorkDirs.size > 0) {
58
80
  try {
59
- fs.unlinkSync(path.join(PTY_SIGNAL_DIR, f));
81
+ const marker = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
82
+ if (marker?.cwd && this._preserveSignalsForWorkDirs.has(this._normalizePath(marker.cwd))) {
83
+ preserve = true;
84
+ }
60
85
  } catch {
61
- // ignore
86
+ // unreadable — fall through and delete
62
87
  }
63
88
  }
89
+ if (preserve) {
90
+ continue;
91
+ }
92
+ try {
93
+ fs.unlinkSync(filePath);
94
+ } catch {
95
+ // ignore
96
+ }
64
97
  }
65
98
  } catch {
66
99
  // ignore
@@ -222,7 +255,33 @@ export class PtyRunner extends EventEmitter {
222
255
  const CHECK_INTERVAL = 5000;
223
256
  const checker = setInterval(() => {
224
257
  const lastActivity = session?._lastActivityTime || 0;
225
- if (lastActivity > 0 && Date.now() - lastActivity > inactivityMs) {
258
+ const idleMs = lastActivity > 0 ? Date.now() - lastActivity : 0;
259
+
260
+ // Idle-prompt fallback: PTY went quiet AND its tail shows Claude's
261
+ // idle status bar — treat as completed even if the Stop hook never
262
+ // produced a signal (happens with resumed sessions on Windows ConPTY).
263
+ if (idleMs > IDLE_PROMPT_FALLBACK_MS && session) {
264
+ const tailRaw = (session._buffer || '').slice(-3000);
265
+ const tailClean = cleanPtyOutput(tailRaw);
266
+ if (IDLE_PROMPT_RE.test(tailClean)) {
267
+ clearInterval(checker);
268
+ this.pendingMarkers.delete(pendingId);
269
+ const fullClean = cleanPtyOutput(session._buffer || '').trim();
270
+ const text = fullClean.length > 2000 ? fullClean.slice(-2000) : fullClean;
271
+ this.logger.warn(`PTY idle-prompt fallback completion in ${session.workDir} (no Stop signal in ${Math.round(idleMs / 1000)}s)`);
272
+ resolve({
273
+ lastAssistantMessage: text,
274
+ sessionId: session.sessionId || null,
275
+ cost: 0,
276
+ numTurns: 0,
277
+ durationMs: idleMs,
278
+ isIdleFallback: true,
279
+ });
280
+ return;
281
+ }
282
+ }
283
+
284
+ if (lastActivity > 0 && idleMs > inactivityMs) {
226
285
  clearInterval(checker);
227
286
  this.pendingMarkers.delete(pendingId);
228
287
  reject(new Error('Marker timeout'));
@@ -0,0 +1,73 @@
1
+ // Persistent map of workDir → last known Claude sessionId.
2
+ // Survives listener restarts so the next task in a workDir can be launched
3
+ // with `claude --resume <sid>` and continue exactly the session that was
4
+ // running before the restart, instead of `--continue` blindly picking the
5
+ // most-recently-modified JSONL (which may belong to a completed turn, a
6
+ // different branch, or a manually-opened terminal in the same project dir).
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { CLAUDE_DIR, normalizeForCompare } from '../bin/constants.js';
11
+
12
+ const SESSION_STATE_PATH = path.join(CLAUDE_DIR, '.session_state.json');
13
+
14
+ function readState () {
15
+ try {
16
+ const raw = fs.readFileSync(SESSION_STATE_PATH, 'utf-8');
17
+ const data = JSON.parse(raw);
18
+ return data && typeof data === 'object' ? data : {};
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ function writeState (state) {
25
+ try {
26
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
27
+ const tmp = `${SESSION_STATE_PATH}.${process.pid}.tmp`;
28
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
29
+ fs.renameSync(tmp, SESSION_STATE_PATH);
30
+ } catch {
31
+ // best-effort; never throw
32
+ }
33
+ }
34
+
35
+ function keyFor (workDir) {
36
+ return normalizeForCompare(workDir);
37
+ }
38
+
39
+ export function getStoredSessionId (workDir) {
40
+ if (!workDir) {
41
+ return null;
42
+ }
43
+ const state = readState();
44
+ const entry = state[keyFor(workDir)];
45
+ return entry?.sessionId || null;
46
+ }
47
+
48
+ export function setStoredSessionId (workDir, sessionId) {
49
+ if (!workDir || !sessionId) {
50
+ return;
51
+ }
52
+ const state = readState();
53
+ state[keyFor(workDir)] = {
54
+ sessionId,
55
+ updatedAt: new Date().toISOString(),
56
+ workDir,
57
+ };
58
+ writeState(state);
59
+ }
60
+
61
+ export function clearStoredSessionId (workDir) {
62
+ if (!workDir) {
63
+ return;
64
+ }
65
+ const state = readState();
66
+ const k = keyFor(workDir);
67
+ if (k in state) {
68
+ delete state[k];
69
+ writeState(state);
70
+ }
71
+ }
72
+
73
+ export { SESSION_STATE_PATH };
package/package.json CHANGED
@@ -1,65 +1,65 @@
1
- {
2
- "name": "claude-notification-plugin",
3
- "productName": "claude-notification-plugin",
4
- "version": "1.1.101",
5
- "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
- "type": "module",
7
- "engines": {
8
- "node": ">=18.0.0"
9
- },
10
- "files": [
11
- ".claude-plugin/",
12
- "bin/",
13
- "claude_img/claude.png",
14
- "hooks/",
15
- "listener/",
16
- "notifier/",
17
- "commit-sha",
18
- "README.md",
19
- "LICENSE"
20
- ],
21
- "bin": {
22
- "claude-notify": "bin/cli.js"
23
- },
24
- "scripts": {
25
- "prepack": "git rev-parse HEAD > commit-sha",
26
- "postinstall": "node bin/install.js",
27
- "lint": "eslint .",
28
- "lint:fix": "eslint --fix .",
29
- "listener:restart": "claude-notify listener restart",
30
- "listener:stop": "claude-notify listener stop",
31
- "listener:start": "claude-notify listener start",
32
- "listener:status": "claude-notify listener status"
33
- },
34
- "keywords": [
35
- "claude",
36
- "claude-code",
37
- "notifications",
38
- "telegram",
39
- "hooks",
40
- "macos",
41
- "linux",
42
- "cross-platform"
43
- ],
44
- "author": {
45
- "name": "Viacheslav Makarov",
46
- "email": "npmjs@bazilio.ru"
47
- },
48
- "license": "MIT",
49
- "repository": {
50
- "type": "git",
51
- "url": "git+https://github.com/Bazilio-san/claude-notification-plugin.git"
52
- },
53
- "homepage": "https://github.com/Bazilio-san/claude-notification-plugin#readme",
54
- "publishConfig": {
55
- "access": "public"
56
- },
57
- "dependencies": {
58
- "node-notifier": "^10.0.1",
59
- "node-pty": "^1.1.0"
60
- },
61
- "devDependencies": {
62
- "eslint-plugin-import": "^2.31.0",
63
- "eslint-plugin-unused-imports": "^4.4.1"
64
- }
65
- }
1
+ {
2
+ "name": "claude-notification-plugin",
3
+ "productName": "claude-notification-plugin",
4
+ "version": "1.1.104",
5
+ "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=18.0.0"
9
+ },
10
+ "files": [
11
+ ".claude-plugin/",
12
+ "bin/",
13
+ "claude_img/claude.png",
14
+ "hooks/",
15
+ "listener/",
16
+ "notifier/",
17
+ "commit-sha",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "bin": {
22
+ "claude-notify": "bin/cli.js"
23
+ },
24
+ "scripts": {
25
+ "prepack": "git rev-parse HEAD > commit-sha",
26
+ "postinstall": "node bin/install.js",
27
+ "lint": "eslint .",
28
+ "lint:fix": "eslint --fix .",
29
+ "listener:restart": "claude-notify listener restart",
30
+ "listener:stop": "claude-notify listener stop",
31
+ "listener:start": "claude-notify listener start",
32
+ "listener:status": "claude-notify listener status"
33
+ },
34
+ "keywords": [
35
+ "claude",
36
+ "claude-code",
37
+ "notifications",
38
+ "telegram",
39
+ "hooks",
40
+ "macos",
41
+ "linux",
42
+ "cross-platform"
43
+ ],
44
+ "author": {
45
+ "name": "Viacheslav Makarov",
46
+ "email": "npmjs@bazilio.ru"
47
+ },
48
+ "license": "MIT",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/Bazilio-san/claude-notification-plugin.git"
52
+ },
53
+ "homepage": "https://github.com/Bazilio-san/claude-notification-plugin#readme",
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
57
+ "dependencies": {
58
+ "node-notifier": "^10.0.1",
59
+ "node-pty": "^1.1.0"
60
+ },
61
+ "devDependencies": {
62
+ "eslint-plugin-import": "^2.31.0",
63
+ "eslint-plugin-unused-imports": "^4.4.1"
64
+ }
65
+ }