claude-notification-plugin 1.1.89 → 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 +212 -5
- 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)
|
|
@@ -218,8 +226,10 @@ async function runWatchdog () {
|
|
|
218
226
|
}
|
|
219
227
|
}
|
|
220
228
|
|
|
221
|
-
// 1. Initial watchdog sweep on startup
|
|
222
|
-
|
|
229
|
+
// 1. Initial watchdog sweep on startup. Must finish before orphan recovery,
|
|
230
|
+
// otherwise orphan recovery sees the stale active (still set) and re-spawns
|
|
231
|
+
// the killed task while watchdog is still awaiting its Telegram notify.
|
|
232
|
+
await runWatchdog();
|
|
223
233
|
|
|
224
234
|
// 2. Re-start orphaned active tasks (PTY sessions lost on restart)
|
|
225
235
|
for (const [workDir, entry] of Object.entries(queue.queues)) {
|
|
@@ -393,6 +403,52 @@ function getClaudeArgs (projectAlias) {
|
|
|
393
403
|
return projectArgs.length > 0 ? projectArgs : globalClaudeArgs;
|
|
394
404
|
}
|
|
395
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
|
+
|
|
396
452
|
function shouldContinueSession (workDir) {
|
|
397
453
|
if (!continueSessionEnabled) {
|
|
398
454
|
return false;
|
|
@@ -506,7 +562,7 @@ async function startTask (workDir, task) {
|
|
|
506
562
|
runningMsgId = await poller.sendMessage(runningRaw);
|
|
507
563
|
}
|
|
508
564
|
task.runningMessageId = runningMsgId;
|
|
509
|
-
const claudeArgs = getClaudeArgs(entry?.project);
|
|
565
|
+
const claudeArgs = applyResumeArgs(getClaudeArgs(entry?.project), workDir);
|
|
510
566
|
try {
|
|
511
567
|
runner.run(workDir, task, claudeArgs, continueSession);
|
|
512
568
|
queue.markStarted(workDir, task.pid || 0);
|
|
@@ -543,7 +599,7 @@ async function startTask (workDir, task) {
|
|
|
543
599
|
|
|
544
600
|
task.runningMessageId = runningMsgId;
|
|
545
601
|
startLiveConsole(workDir, runningMsgId, runningFull);
|
|
546
|
-
const claudeArgs = getClaudeArgs(entry?.project);
|
|
602
|
+
const claudeArgs = applyResumeArgs(getClaudeArgs(entry?.project), workDir);
|
|
547
603
|
try {
|
|
548
604
|
runner.run(workDir, task, claudeArgs, continueSession);
|
|
549
605
|
queue.markStarted(workDir, task.pid || 0);
|
|
@@ -631,6 +687,12 @@ async function handleCommand (cmd, args) {
|
|
|
631
687
|
return handleHistory();
|
|
632
688
|
case '/pty':
|
|
633
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);
|
|
634
696
|
case '/stop':
|
|
635
697
|
return handleStop();
|
|
636
698
|
case '/help':
|
|
@@ -1256,6 +1318,144 @@ PTY log: <code>${info.hasLogStream ? 'writing' : 'off'}</code>
|
|
|
1256
1318
|
<pre>${escapeHtml(lastLines)}</pre>`;
|
|
1257
1319
|
}
|
|
1258
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
|
+
|
|
1259
1459
|
function handleHistory () {
|
|
1260
1460
|
const history = queue.getHistory(10);
|
|
1261
1461
|
if (history.length === 0) {
|
|
@@ -1290,6 +1490,9 @@ const MENU_KEYBOARD = {
|
|
|
1290
1490
|
[
|
|
1291
1491
|
{ text: '📜 History', callback_data: '/history' },
|
|
1292
1492
|
{ text: '🖥 PTY', callback_data: '/pty' },
|
|
1493
|
+
{ text: '📋 Sessions', callback_data: '/sessions' },
|
|
1494
|
+
],
|
|
1495
|
+
[
|
|
1293
1496
|
{ text: '🏠 Default', callback_data: '/setdefault' },
|
|
1294
1497
|
],
|
|
1295
1498
|
[
|
|
@@ -1318,6 +1521,9 @@ function handleHelp () {
|
|
|
1318
1521
|
/worktree &project/branch — create worktree
|
|
1319
1522
|
/rmworktree &project/branch — remove worktree
|
|
1320
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
|
|
1321
1527
|
/history — task history
|
|
1322
1528
|
/stop — stop listener
|
|
1323
1529
|
/menu — command buttons
|
|
@@ -1476,6 +1682,7 @@ async function mainLoop () {
|
|
|
1476
1682
|
{ command: 'setdefault', description: 'Change default project' },
|
|
1477
1683
|
{ command: 'history', description: 'Recent task history' },
|
|
1478
1684
|
{ command: 'pty', description: 'PTY session diagnostics' },
|
|
1685
|
+
{ command: 'sessions', description: 'List recent CC sessions' },
|
|
1479
1686
|
{ command: 'help', description: 'Show all commands' },
|
|
1480
1687
|
{ command: 'stop', description: 'Stop listener' },
|
|
1481
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": {
|