claude-notification-plugin 1.1.90 → 1.1.91

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.90",
3
+ "version": "1.1.91",
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
@@ -90,6 +90,9 @@ Config file: `~/.claude/claude-notify.config.json`
90
90
  }
91
91
  },
92
92
  "continueSession": true,
93
+ "resumeLastSession": true,
94
+ "sessionsListLimit": 5,
95
+ "sessionWorkingThresholdSec": 2,
93
96
  "worktreeBaseDir": "abs-path-to-worktrees-root",
94
97
  "autoCreateWorktree": true,
95
98
  "taskTimeoutMinutes": 30,
@@ -259,6 +262,9 @@ Projects are referenced with the `&` prefix (e.g. `&api`, `&api/branch`).
259
262
  | `/worktree &project/branch` | Create a worktree |
260
263
  | `/rmworktree &project/branch` | Remove a worktree |
261
264
  | `/pty [&project[/branch]]` | PTY session diagnostics (state, buffer, output) |
265
+ | `/sessions [&project[/branch]]` | List recent CC sessions in the project's `~/.claude/projects/<dir>/`, with status (free / alive idle / working) and a Resume / Kill & Resume button per row |
266
+ | `/resume &project[/branch] <sessionId>` | Mark the next task to resume `<sessionId>` (`claude --resume <id>`) |
267
+ | `/kresume &project[/branch] <sessionId>` | Same, but kill the process holding the JSONL first |
262
268
  | `/history` | Recent task history |
263
269
  | `/stop` | Stop the listener |
264
270
  | `/start` | Show help with inline buttons |
@@ -321,7 +327,10 @@ the same path cannot be registered twice under different aliases.
321
327
  |----------------------|-----------------------|----------------------------------------------------------------------------------------|
322
328
  | `projects` | (required) | Map of projects: `alias → { path }` |
323
329
  | `claudeArgs` | `[]` | Extra CLI args for Claude (e.g. `["--permission-mode", "auto"]`) |
324
- | `continueSession` | `true` | Continue previous session context (`--continue` flag). Claude remembers previous tasks |
330
+ | `continueSession` | `true` | Keep the PTY alive between Telegram tasks so subsequent tasks reuse the same live Claude session |
331
+ | `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
+ | `sessionsListLimit` | `5` | How many most-recent sessions `/sessions` shows |
333
+ | `sessionWorkingThresholdSec` | `2` | A locked JSONL with mtime ≤ this many seconds is considered actively writing (status `working`, resume disabled) |
325
334
  | `worktreeBaseDir` | `~/.claude/worktrees` | Where auto-created worktrees are stored |
326
335
  | `autoCreateWorktree` | `true` | Auto-create worktrees for unknown branches |
327
336
  | `taskTimeoutMinutes` | `30` | Max task execution time (force-stopped when exceeded) |
package/commit-sha CHANGED
@@ -1 +1 @@
1
- 3ff3888c80833878b3240fd6344ed8f5d3d06c17
1
+ 01bee72db3baded113d020fadd1a94fb262fddc1
@@ -0,0 +1,222 @@
1
+ import { spawn } from 'child_process';
2
+
3
+ // PowerShell + Win32 Restart Manager API. Compiles a tiny C# helper on first
4
+ // run (~1–2s cold start) and reuses it for the life of the process. Reads
5
+ // file paths from stdin (one per line, UTF-8) — avoids argv escaping issues
6
+ // for paths with spaces, quotes, or non-ASCII characters.
7
+ const PS_SCRIPT = `
8
+ $ErrorActionPreference = 'Stop'
9
+ $OutputEncoding = [System.Text.Encoding]::UTF8
10
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
11
+ [Console]::InputEncoding = [System.Text.Encoding]::UTF8
12
+ $src = @'
13
+ using System;
14
+ using System.Collections.Generic;
15
+ using System.Runtime.InteropServices;
16
+ namespace ResMgr {
17
+ public static class Locker {
18
+ [StructLayout(LayoutKind.Sequential)]
19
+ struct RM_UNIQUE_PROCESS { public int dwProcessId; public System.Runtime.InteropServices.ComTypes.FILETIME t; }
20
+ const int N_APP = 256; const int N_SVC = 64;
21
+ [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
22
+ struct RM_PROCESS_INFO {
23
+ public RM_UNIQUE_PROCESS Process;
24
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst=N_APP)] public string strAppName;
25
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst=N_SVC)] public string strServiceShortName;
26
+ public uint ApplicationType; public uint AppStatus; public uint TSSessionId;
27
+ [MarshalAs(UnmanagedType.Bool)] public bool bRestartable;
28
+ }
29
+ [DllImport("rstrtmgr.dll", CharSet=CharSet.Unicode)]
30
+ static extern int RmStartSession(out uint h, int f, string k);
31
+ [DllImport("rstrtmgr.dll")] static extern int RmEndSession(uint h);
32
+ [DllImport("rstrtmgr.dll", CharSet=CharSet.Unicode)]
33
+ static extern int RmRegisterResources(uint h, uint nF, string[] f, uint nA, [In] RM_UNIQUE_PROCESS[] a, uint nS, string[] s);
34
+ [DllImport("rstrtmgr.dll")]
35
+ static extern int RmGetList(uint h, out uint need, ref uint cnt, [In, Out] RM_PROCESS_INFO[] info, ref uint reason);
36
+ public static List<int> WhoIsLocking(string path) {
37
+ var pids = new List<int>();
38
+ uint handle;
39
+ if (RmStartSession(out handle, 0, Guid.NewGuid().ToString()) != 0) return pids;
40
+ try {
41
+ if (RmRegisterResources(handle, 1, new []{path}, 0, null, 0, null) != 0) return pids;
42
+ uint need = 0, cnt = 0, reason = 0;
43
+ int res = RmGetList(handle, out need, ref cnt, null, ref reason);
44
+ if (res == 234 && need > 0) {
45
+ var info = new RM_PROCESS_INFO[need]; cnt = need;
46
+ if (RmGetList(handle, out need, ref cnt, info, ref reason) == 0) {
47
+ for (int i = 0; i < cnt; i++) pids.Add(info[i].Process.dwProcessId);
48
+ }
49
+ }
50
+ } finally { RmEndSession(handle); }
51
+ return pids;
52
+ }
53
+ }
54
+ }
55
+ '@
56
+ Add-Type -TypeDefinition $src -Language CSharp -ErrorAction Stop | Out-Null
57
+ $line = $null
58
+ while (($line = [Console]::In.ReadLine()) -ne $null) {
59
+ if ($line -eq '') { continue }
60
+ try {
61
+ $pids = [ResMgr.Locker]::WhoIsLocking($line)
62
+ Write-Output ("OK\`t" + $line + "\`t" + ($pids -join ','))
63
+ } catch {
64
+ Write-Output ("ERR\`t" + $line + "\`t" + $_.Exception.Message)
65
+ }
66
+ }
67
+ `;
68
+
69
+ /**
70
+ * Find PIDs of processes that have an open handle to each given file path.
71
+ * Returns a Map<filePath, number[]>. Paths absent from the map mean "no
72
+ * detection (tool unavailable or error)" — callers should treat as unknown,
73
+ * not as "free".
74
+ *
75
+ * On Windows, spawns a single PowerShell with the Restart Manager API and
76
+ * feeds all paths via stdin to amortize PowerShell's ~1–2s cold start.
77
+ * On Linux/macOS, uses `lsof -F p` for the same.
78
+ */
79
+ export async function findLocking (filePaths, logger) {
80
+ if (!filePaths || filePaths.length === 0) {
81
+ return new Map();
82
+ }
83
+ if (process.platform === 'win32') {
84
+ return findLockingWindows(filePaths, logger);
85
+ }
86
+ return findLockingUnix(filePaths, logger);
87
+ }
88
+
89
+ function findLockingWindows (filePaths, logger) {
90
+ return new Promise((resolve) => {
91
+ const result = new Map();
92
+ let proc;
93
+ try {
94
+ proc = spawn('powershell.exe', [
95
+ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
96
+ '-Command', PS_SCRIPT,
97
+ ], { windowsHide: true });
98
+ } catch (err) {
99
+ logger?.warn(`file-locks: spawn powershell failed: ${err.message}`);
100
+ resolve(result);
101
+ return;
102
+ }
103
+
104
+ let stdout = '';
105
+ let stderr = '';
106
+ let settled = false;
107
+ const finish = () => {
108
+ if (settled) {
109
+ return;
110
+ }
111
+ settled = true;
112
+ for (const line of stdout.split(/\r?\n/)) {
113
+ if (!line.startsWith('OK\t')) {
114
+ continue;
115
+ }
116
+ const parts = line.split('\t');
117
+ const filePath = parts[1];
118
+ const pids = (parts[2] || '').split(',').map(Number).filter(n => Number.isInteger(n) && n > 0);
119
+ result.set(filePath, pids);
120
+ }
121
+ if (stderr.trim() && logger) {
122
+ logger.warn(`file-locks: powershell stderr: ${stderr.trim().slice(0, 200)}`);
123
+ }
124
+ resolve(result);
125
+ };
126
+
127
+ proc.stdout.on('data', (d) => {
128
+ stdout += d.toString('utf-8');
129
+ });
130
+ proc.stderr.on('data', (d) => {
131
+ stderr += d.toString('utf-8');
132
+ });
133
+ proc.on('close', finish);
134
+ proc.on('error', (err) => {
135
+ logger?.warn(`file-locks: powershell error: ${err.message}`);
136
+ finish();
137
+ });
138
+
139
+ const timer = setTimeout(() => {
140
+ try {
141
+ proc.kill();
142
+ } catch { /* ignore */ }
143
+ logger?.warn('file-locks: powershell timed out (15s)');
144
+ finish();
145
+ }, 15_000);
146
+ proc.on('close', () => clearTimeout(timer));
147
+
148
+ try {
149
+ proc.stdin.write(filePaths.join('\n') + '\n', 'utf-8');
150
+ proc.stdin.end();
151
+ } catch (err) {
152
+ logger?.warn(`file-locks: stdin write failed: ${err.message}`);
153
+ finish();
154
+ }
155
+ });
156
+ }
157
+
158
+ function findLockingUnix (filePaths, logger) {
159
+ return new Promise((resolve) => {
160
+ const result = new Map();
161
+ let proc;
162
+ try {
163
+ proc = spawn('lsof', ['-F', 'pn', '--', ...filePaths]);
164
+ } catch (err) {
165
+ logger?.warn(`file-locks: spawn lsof failed: ${err.message}`);
166
+ resolve(result);
167
+ return;
168
+ }
169
+ let stdout = '';
170
+ proc.stdout.on('data', (d) => {
171
+ stdout += d.toString('utf-8');
172
+ });
173
+ proc.on('error', () => resolve(result));
174
+ proc.on('close', () => {
175
+ // lsof -F pn: lines starting with `p` are PIDs, `n` are file names.
176
+ // Parse pairs: each `n<path>` belongs to the most recent `p<pid>`.
177
+ let currentPid = 0;
178
+ const pathSet = new Set(filePaths);
179
+ for (const line of stdout.split('\n')) {
180
+ if (line.startsWith('p')) {
181
+ currentPid = parseInt(line.slice(1), 10) || 0;
182
+ } else if (line.startsWith('n') && currentPid > 0) {
183
+ const p = line.slice(1);
184
+ if (pathSet.has(p)) {
185
+ const arr = result.get(p) || [];
186
+ if (!arr.includes(currentPid)) {
187
+ arr.push(currentPid);
188
+ }
189
+ result.set(p, arr);
190
+ }
191
+ }
192
+ }
193
+ resolve(result);
194
+ });
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Forcefully kill a process by PID. Returns true on success.
200
+ */
201
+ export async function killPid (pid, logger) {
202
+ if (!Number.isInteger(pid) || pid <= 0) {
203
+ return false;
204
+ }
205
+ if (process.platform === 'win32') {
206
+ return new Promise((resolve) => {
207
+ const proc = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
208
+ proc.on('close', (code) => resolve(code === 0));
209
+ proc.on('error', (err) => {
210
+ logger?.warn(`file-locks: taskkill error: ${err.message}`);
211
+ resolve(false);
212
+ });
213
+ });
214
+ }
215
+ try {
216
+ process.kill(pid, 'SIGKILL');
217
+ return true;
218
+ } catch (err) {
219
+ logger?.warn(`file-locks: kill ${pid} failed: ${err.message}`);
220
+ return false;
221
+ }
222
+ }
@@ -22,7 +22,9 @@ import {
22
22
  normalizeForCompare,
23
23
  loadSeenProjects,
24
24
  } from '../bin/constants.js';
25
- import { JsonlReader, resolveJsonlPath, resolveJsonlByMtime } from './jsonl-reader.js';
25
+ import { JsonlReader, resolveJsonlPath, resolveJsonlByMtime, cwdToProjectDir } from './jsonl-reader.js';
26
+ import { listSessions } from './session-list.js';
27
+ import { findLocking, killPid } from './file-locks.js';
26
28
 
27
29
  // ----------------------
28
30
  // CRASH PROTECTION
@@ -122,6 +124,9 @@ const globalClaudeArgs = listenerConfig.claudeArgs || [];
122
124
  const continueSessionEnabled = listenerConfig.continueSession !== false; // default: true
123
125
  const taskTimeoutMinutes = listenerConfig.taskTimeoutMinutes || 30;
124
126
  const taskTimeout = taskTimeoutMinutes * 60_000;
127
+ const resumeLastSessionEnabled = listenerConfig.resumeLastSession !== false; // default: true
128
+ const sessionsListLimit = listenerConfig.sessionsListLimit || 5;
129
+ const sessionWorkingThresholdSec = listenerConfig.sessionWorkingThresholdSec || 2;
125
130
 
126
131
  const poller = new TelegramPoller(token, chatId, logger);
127
132
  const queue = new WorkQueue(
@@ -147,6 +152,9 @@ const startTime = Date.now();
147
152
  const sessions = new Map();
148
153
  // WorkDirs that should start a fresh session on next task
149
154
  const freshSessionDirs = new Set();
155
+ // WorkDirs with a pending resume request: workDir -> sessionId. Consumed by
156
+ // applyResumeArgs() on the next task, then cleared.
157
+ const pendingResumeBySid = new Map();
150
158
  // Live console intervals per workDir
151
159
  const liveConsoleTimers = new Map();
152
160
  // JSONL readers per workDir (for live console from structured session data)
@@ -395,6 +403,52 @@ function getClaudeArgs (projectAlias) {
395
403
  return projectArgs.length > 0 ? projectArgs : globalClaudeArgs;
396
404
  }
397
405
 
406
+ function hasAnyJsonl (workDir) {
407
+ const dirName = cwdToProjectDir(workDir);
408
+ const dirPath = path.join(CLAUDE_DIR, 'projects', dirName);
409
+ try {
410
+ return fs.readdirSync(dirPath).some(f => f.endsWith('.jsonl'));
411
+ } catch {
412
+ return false;
413
+ }
414
+ }
415
+
416
+ // Decide whether to add --continue or --resume <id> to claudeArgs for a fresh PTY.
417
+ // A pending /resume <sid> wins over the global resumeLastSession default.
418
+ // If args already contain a resume flag, leave them alone. If reusing an idle
419
+ // PTY, claudeArgs are ignored anyway.
420
+ function applyResumeArgs (claudeArgs, workDir) {
421
+ if (claudeArgs.includes('--continue') || claudeArgs.includes('--resume')) {
422
+ return claudeArgs;
423
+ }
424
+ if (pendingResumeBySid.has(workDir)) {
425
+ const sid = pendingResumeBySid.get(workDir);
426
+ pendingResumeBySid.delete(workDir);
427
+ return [...claudeArgs, '--resume', sid];
428
+ }
429
+ if (resumeLastSessionEnabled && hasAnyJsonl(workDir)) {
430
+ return [...claudeArgs, '--continue'];
431
+ }
432
+ return claudeArgs;
433
+ }
434
+
435
+ function formatTimeAgo (ms) {
436
+ const sec = Math.max(0, Math.floor(ms / 1000));
437
+ if (sec < 60) {
438
+ return `${sec}s ago`;
439
+ }
440
+ const min = Math.floor(sec / 60);
441
+ if (min < 60) {
442
+ return `${min}m ago`;
443
+ }
444
+ const h = Math.floor(min / 60);
445
+ if (h < 24) {
446
+ return `${h}h ago`;
447
+ }
448
+ const d = Math.floor(h / 24);
449
+ return `${d}d ago`;
450
+ }
451
+
398
452
  function shouldContinueSession (workDir) {
399
453
  if (!continueSessionEnabled) {
400
454
  return false;
@@ -508,7 +562,7 @@ async function startTask (workDir, task) {
508
562
  runningMsgId = await poller.sendMessage(runningRaw);
509
563
  }
510
564
  task.runningMessageId = runningMsgId;
511
- const claudeArgs = getClaudeArgs(entry?.project);
565
+ const claudeArgs = applyResumeArgs(getClaudeArgs(entry?.project), workDir);
512
566
  try {
513
567
  runner.run(workDir, task, claudeArgs, continueSession);
514
568
  queue.markStarted(workDir, task.pid || 0);
@@ -545,7 +599,7 @@ async function startTask (workDir, task) {
545
599
 
546
600
  task.runningMessageId = runningMsgId;
547
601
  startLiveConsole(workDir, runningMsgId, runningFull);
548
- const claudeArgs = getClaudeArgs(entry?.project);
602
+ const claudeArgs = applyResumeArgs(getClaudeArgs(entry?.project), workDir);
549
603
  try {
550
604
  runner.run(workDir, task, claudeArgs, continueSession);
551
605
  queue.markStarted(workDir, task.pid || 0);
@@ -633,6 +687,12 @@ async function handleCommand (cmd, args) {
633
687
  return handleHistory();
634
688
  case '/pty':
635
689
  return handlePty(args);
690
+ case '/sessions':
691
+ return handleSessions(args);
692
+ case '/resume':
693
+ return handleResumeSession(args, false);
694
+ case '/kresume':
695
+ return handleResumeSession(args, true);
636
696
  case '/stop':
637
697
  return handleStop();
638
698
  case '/help':
@@ -1258,6 +1318,144 @@ PTY log: <code>${info.hasLogStream ? 'writing' : 'off'}</code>
1258
1318
  <pre>${escapeHtml(lastLines)}</pre>`;
1259
1319
  }
1260
1320
 
1321
+ async function handleSessions (args) {
1322
+ let target = parseTarget(args);
1323
+ if (!target) {
1324
+ const def = getDefaultProject(config);
1325
+ if (!def) {
1326
+ return 'Usage: /sessions &project[/branch]';
1327
+ }
1328
+ target = { project: def, branch: null };
1329
+ }
1330
+ let workDir;
1331
+ try {
1332
+ workDir = worktreeManager.resolveWorkDir(target.project, target.branch);
1333
+ } catch (err) {
1334
+ return `❌ ${escapeHtml(err.message)}`;
1335
+ }
1336
+ const labelTarget = target.branch && target.branch !== 'main' && target.branch !== 'master'
1337
+ ? `&${target.project}/${target.branch}`
1338
+ : `&${target.project}`;
1339
+
1340
+ const items = await listSessions(workDir, {
1341
+ limit: sessionsListLimit,
1342
+ workingThresholdSec: sessionWorkingThresholdSec,
1343
+ logger,
1344
+ });
1345
+ if (items.length === 0) {
1346
+ return `📋 No sessions found for ${escapeHtml(labelTarget)}`;
1347
+ }
1348
+
1349
+ let text = `📋 <b>Sessions for ${escapeHtml(labelTarget)}</b> (${items.length} most recent)`;
1350
+ const buttons = [];
1351
+ let idx = 0;
1352
+ for (const s of items) {
1353
+ idx++;
1354
+ const ago = formatTimeAgo(Date.now() - s.mtime);
1355
+ const sizeKb = s.size / 1024;
1356
+ const sizeStr = sizeKb >= 1024 ? `${(sizeKb / 1024).toFixed(1)} MB` : `${Math.round(sizeKb)} KB`;
1357
+ let icon;
1358
+ let statusLabel;
1359
+ if (s.status === 'working') {
1360
+ icon = '🔴';
1361
+ statusLabel = 'working';
1362
+ } else if (s.status === 'idle') {
1363
+ icon = '🟡';
1364
+ statusLabel = 'alive idle';
1365
+ } else {
1366
+ icon = '🟢';
1367
+ statusLabel = 'free';
1368
+ }
1369
+ const lockInfo = s.lockedBy.length > 0 ? ` · pid ${s.lockedBy.join(',')}` : '';
1370
+ const preview = s.preview ? escapeHtml(s.preview) : '<i>(no user message yet)</i>';
1371
+ text += `\n\n<b>${idx}.</b> ${icon} ${statusLabel} · ${ago} · ${sizeStr}${lockInfo}\n<code>${s.sessionId}</code>\n${preview}`;
1372
+
1373
+ if (s.status === 'working') {
1374
+ // Skip — resuming a file being actively written would corrupt JSONL.
1375
+ } else if (s.status === 'idle') {
1376
+ buttons.push([
1377
+ { text: `${idx}. ⚠️ Kill & Resume`, callback_data: `/kresume ${labelTarget} ${s.sessionId}` },
1378
+ ]);
1379
+ } else {
1380
+ buttons.push([
1381
+ { text: `${idx}. ▶ Resume`, callback_data: `/resume ${labelTarget} ${s.sessionId}` },
1382
+ ]);
1383
+ }
1384
+ }
1385
+
1386
+ if (buttons.length === 0) {
1387
+ return text + '\n\n<i>All sessions are actively in use — cannot resume safely.</i>';
1388
+ }
1389
+ return { text, replyMarkup: { inline_keyboard: buttons } };
1390
+ }
1391
+
1392
+ async function handleResumeSession (args, kill) {
1393
+ const tokens = (args || '').trim().split(/\s+/).filter(Boolean);
1394
+ const cmdName = kill ? '/kresume' : '/resume';
1395
+ if (tokens.length < 2) {
1396
+ return `Usage: ${cmdName} &project[/branch] &lt;sessionId&gt;`;
1397
+ }
1398
+ const target = parseTarget(tokens[0]);
1399
+ if (!target) {
1400
+ return `Invalid project alias: ${escapeHtml(tokens[0])}`;
1401
+ }
1402
+ const sessionId = tokens[1];
1403
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
1404
+ return `Invalid session ID: <code>${escapeHtml(sessionId)}</code>`;
1405
+ }
1406
+
1407
+ let workDir;
1408
+ try {
1409
+ workDir = worktreeManager.resolveWorkDir(target.project, target.branch);
1410
+ } catch (err) {
1411
+ return `❌ ${escapeHtml(err.message)}`;
1412
+ }
1413
+ const labelTarget = target.branch && target.branch !== 'main' && target.branch !== 'master'
1414
+ ? `&${target.project}/${target.branch}`
1415
+ : `&${target.project}`;
1416
+
1417
+ const dirName = cwdToProjectDir(workDir);
1418
+ const filePath = path.join(CLAUDE_DIR, 'projects', dirName, `${sessionId}.jsonl`);
1419
+ if (!fs.existsSync(filePath)) {
1420
+ return `Session not found: <code>${escapeHtml(sessionId)}</code>`;
1421
+ }
1422
+
1423
+ let killReport = '';
1424
+ if (kill) {
1425
+ const lockMap = await findLocking([filePath], logger);
1426
+ const pids = lockMap.get(filePath) || [];
1427
+ if (pids.length === 0) {
1428
+ killReport = '\n<i>No active locker found — proceeding with resume.</i>';
1429
+ } else {
1430
+ let killed = 0;
1431
+ for (const pid of pids) {
1432
+ if (await killPid(pid, logger)) {
1433
+ killed++;
1434
+ }
1435
+ }
1436
+ logger.info(`/kresume: killed ${killed}/${pids.length} processes locking ${sessionId}: ${pids.join(',')}`);
1437
+ // Brief settle delay so the OS releases the file handle.
1438
+ await new Promise((r) => setTimeout(r, 500));
1439
+ killReport = `\n<i>Killed ${killed}/${pids.length} process(es): ${pids.join(', ')}</i>`;
1440
+ }
1441
+ }
1442
+
1443
+ // Drop any live PTY in this workDir so the next task spawns a fresh one with --resume.
1444
+ if (runner.isPtyAlive(workDir)) {
1445
+ try {
1446
+ runner.cancel(workDir);
1447
+ } catch (err) {
1448
+ logger.warn(`/resume: cancel PTY failed: ${err.message}`);
1449
+ }
1450
+ stopLiveConsole(workDir);
1451
+ runner.cleanActivitySignal(workDir);
1452
+ }
1453
+ freshSessionDirs.add(workDir);
1454
+ pendingResumeBySid.set(workDir, sessionId);
1455
+
1456
+ return `🔄 ${escapeHtml(labelTarget)}: next task will resume <code>${sessionId.slice(0, 8)}…</code>${killReport}\n\nSend the task as usual:\n<code>${escapeHtml(labelTarget)} your task here</code>`;
1457
+ }
1458
+
1261
1459
  function handleHistory () {
1262
1460
  const history = queue.getHistory(10);
1263
1461
  if (history.length === 0) {
@@ -1292,6 +1490,9 @@ const MENU_KEYBOARD = {
1292
1490
  [
1293
1491
  { text: '📜 History', callback_data: '/history' },
1294
1492
  { text: '🖥 PTY', callback_data: '/pty' },
1493
+ { text: '📋 Sessions', callback_data: '/sessions' },
1494
+ ],
1495
+ [
1295
1496
  { text: '🏠 Default', callback_data: '/setdefault' },
1296
1497
  ],
1297
1498
  [
@@ -1320,6 +1521,9 @@ function handleHelp () {
1320
1521
  /worktree &project/branch — create worktree
1321
1522
  /rmworktree &project/branch — remove worktree
1322
1523
  /pty [&project[/branch]] — PTY session diagnostics
1524
+ /sessions [&project[/branch]] — list 5 most recent CC sessions with resume buttons
1525
+ /resume &project[/branch] &lt;sessionId&gt; — next task resumes the given session
1526
+ /kresume &project[/branch] &lt;sessionId&gt; — kill the holder, then resume
1323
1527
  /history — task history
1324
1528
  /stop — stop listener
1325
1529
  /menu — command buttons
@@ -1478,6 +1682,7 @@ async function mainLoop () {
1478
1682
  { command: 'setdefault', description: 'Change default project' },
1479
1683
  { command: 'history', description: 'Recent task history' },
1480
1684
  { command: 'pty', description: 'PTY session diagnostics' },
1685
+ { command: 'sessions', description: 'List recent CC sessions' },
1481
1686
  { command: 'help', description: 'Show all commands' },
1482
1687
  { command: 'stop', description: 'Stop listener' },
1483
1688
  ]);
@@ -632,6 +632,14 @@ export class PtyRunner extends EventEmitter {
632
632
  return session?.state === 'busy';
633
633
  }
634
634
 
635
+ /**
636
+ * Check if a PTY session exists for a workDir (busy or idle).
637
+ * Used to decide whether to kill before spawning a fresh session for --resume.
638
+ */
639
+ isPtyAlive (workDir) {
640
+ return this.sessions.has(workDir);
641
+ }
642
+
635
643
  /**
636
644
  * Get active task info for a workDir.
637
645
  */
@@ -0,0 +1,142 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { CLAUDE_DIR } from '../bin/constants.js';
4
+ import { cwdToProjectDir } from './jsonl-reader.js';
5
+ import { findLocking } from './file-locks.js';
6
+
7
+ const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
8
+
9
+ const PREVIEW_MAX_LINES = 200;
10
+ const PREVIEW_MAX_CHARS = 120;
11
+
12
+ /**
13
+ * List the most recent Claude Code sessions (JSONL files) for a workDir,
14
+ * with status and a short preview of the first user message.
15
+ *
16
+ * Status semantics:
17
+ * - working: file is locked AND mtime within workingThresholdSec
18
+ * - idle: file is locked AND older than workingThresholdSec
19
+ * - free: file is not locked
20
+ *
21
+ * @param {string} workDir cwd whose project dir to scan
22
+ * @param {object} opts
23
+ * @param {number} opts.limit max sessions to return
24
+ * @param {number} opts.workingThresholdSec mtime ≤ this AND locked → working
25
+ * @param {object} opts.logger
26
+ * @returns {Promise<Array<{sessionId, filePath, mtime, size, status, lockedBy, preview}>>}
27
+ */
28
+ export async function listSessions (workDir, { limit = 5, workingThresholdSec = 2, logger } = {}) {
29
+ const dirName = cwdToProjectDir(workDir);
30
+ const dirPath = path.join(PROJECTS_DIR, dirName);
31
+
32
+ let entries;
33
+ try {
34
+ entries = fs.readdirSync(dirPath);
35
+ } catch {
36
+ return [];
37
+ }
38
+
39
+ const candidates = [];
40
+ for (const name of entries) {
41
+ if (!name.endsWith('.jsonl')) {
42
+ continue;
43
+ }
44
+ const filePath = path.join(dirPath, name);
45
+ let stat;
46
+ try {
47
+ stat = fs.statSync(filePath);
48
+ } catch {
49
+ continue;
50
+ }
51
+ if (!stat.isFile() || stat.size === 0) {
52
+ continue;
53
+ }
54
+ candidates.push({
55
+ sessionId: name.slice(0, -'.jsonl'.length),
56
+ filePath,
57
+ mtime: stat.mtimeMs,
58
+ size: stat.size,
59
+ });
60
+ }
61
+
62
+ candidates.sort((a, b) => b.mtime - a.mtime);
63
+ const top = candidates.slice(0, limit);
64
+
65
+ if (top.length === 0) {
66
+ return [];
67
+ }
68
+
69
+ const lockMap = await findLocking(top.map(s => s.filePath), logger);
70
+ const now = Date.now();
71
+ const workingMs = workingThresholdSec * 1000;
72
+
73
+ for (const s of top) {
74
+ const pids = lockMap.get(s.filePath) || [];
75
+ s.lockedBy = pids;
76
+ if (pids.length > 0) {
77
+ s.status = (now - s.mtime) <= workingMs ? 'working' : 'idle';
78
+ } else {
79
+ s.status = 'free';
80
+ }
81
+ s.preview = readFirstUserMessage(s.filePath);
82
+ }
83
+
84
+ return top;
85
+ }
86
+
87
+ /**
88
+ * Read the first user-typed message text from a JSONL session file.
89
+ * Returns a short single-line preview, or an empty string if not found.
90
+ */
91
+ function readFirstUserMessage (filePath) {
92
+ let buf;
93
+ try {
94
+ // Read up to 256 KB — first user message is almost always very early.
95
+ const fd = fs.openSync(filePath, 'r');
96
+ const chunk = Buffer.alloc(256 * 1024);
97
+ const n = fs.readSync(fd, chunk, 0, chunk.length, 0);
98
+ fs.closeSync(fd);
99
+ buf = chunk.slice(0, n).toString('utf-8');
100
+ } catch {
101
+ return '';
102
+ }
103
+
104
+ const lines = buf.split('\n');
105
+ const max = Math.min(lines.length, PREVIEW_MAX_LINES);
106
+ for (let i = 0; i < max; i++) {
107
+ const line = lines[i];
108
+ if (!line || line[0] !== '{') {
109
+ continue;
110
+ }
111
+ let rec;
112
+ try {
113
+ rec = JSON.parse(line);
114
+ } catch {
115
+ continue;
116
+ }
117
+ if (rec.type !== 'user' || !rec.message) {
118
+ continue;
119
+ }
120
+ const content = rec.message.content;
121
+ let text = '';
122
+ if (typeof content === 'string') {
123
+ text = content;
124
+ } else if (Array.isArray(content)) {
125
+ for (const part of content) {
126
+ if (part && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string') {
127
+ text = part.text;
128
+ break;
129
+ }
130
+ }
131
+ }
132
+ if (!text) {
133
+ continue;
134
+ }
135
+ text = text.replace(/\s+/g, ' ').trim();
136
+ if (text.length > PREVIEW_MAX_CHARS) {
137
+ text = text.slice(0, PREVIEW_MAX_CHARS - 1) + '…';
138
+ }
139
+ return text;
140
+ }
141
+ return '';
142
+ }
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.90",
4
+ "version": "1.1.91",
5
5
  "description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
6
6
  "type": "module",
7
7
  "engines": {