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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +10 -1
- package/commit-sha +1 -1
- package/listener/file-locks.js +222 -0
- package/listener/listener.js +208 -3
- package/listener/pty-runner.js +8 -0
- package/listener/session-list.js +142 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-notification-plugin",
|
|
3
|
-
"version": "1.1.
|
|
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` |
|
|
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
|
-
|
|
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
|
+
}
|
package/listener/listener.js
CHANGED
|
@@ -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] <sessionId>`;
|
|
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] <sessionId> — next task resumes the given session
|
|
1526
|
+
/kresume &project[/branch] <sessionId> — 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
|
]);
|
package/listener/pty-runner.js
CHANGED
|
@@ -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.
|
|
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": {
|