claude-notification-plugin 1.0.59 → 1.0.63

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.
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+
3
+ const COMMANDS = [
4
+ '/status', '/queue', '/cancel', '/drop', '/clear',
5
+ '/projects', '/worktrees', '/worktree', '/rmworktree',
6
+ '/history', '/stop', '/help',
7
+ ];
8
+
9
+ /**
10
+ * Parse a Telegram message into a command or task.
11
+ *
12
+ * Formats:
13
+ * /command args → { type: 'command', cmd, args }
14
+ * @project/branch text → { type: 'task', project, branch, text }
15
+ * @project text → { type: 'task', project, branch: null, text }
16
+ * text → { type: 'task', project: 'default', branch: null, text }
17
+ */
18
+ export function parseMessage (text) {
19
+ if (!text || typeof text !== 'string') {
20
+ return null;
21
+ }
22
+
23
+ const trimmed = text.trim();
24
+ if (!trimmed) {
25
+ return null;
26
+ }
27
+
28
+ // Check for commands
29
+ if (trimmed.startsWith('/')) {
30
+ const parts = trimmed.split(/\s+/);
31
+ const cmd = parts[0].toLowerCase().replace(/@\w+$/, ''); // strip @botname
32
+ if (COMMANDS.includes(cmd)) {
33
+ return {
34
+ type: 'command',
35
+ cmd,
36
+ args: parts.slice(1).join(' '),
37
+ };
38
+ }
39
+ }
40
+
41
+ // Check for @project/branch or @project prefix
42
+ const projectMatch = trimmed.match(/^@(\S+)\s+([\s\S]+)$/);
43
+ if (projectMatch) {
44
+ const target = projectMatch[1];
45
+ const taskText = projectMatch[2].trim();
46
+
47
+ // Split target into project and optional branch
48
+ const slashIndex = target.indexOf('/');
49
+ if (slashIndex > 0) {
50
+ return {
51
+ type: 'task',
52
+ project: target.substring(0, slashIndex),
53
+ branch: target.substring(slashIndex + 1),
54
+ text: taskText,
55
+ };
56
+ }
57
+ return {
58
+ type: 'task',
59
+ project: target,
60
+ branch: null,
61
+ text: taskText,
62
+ };
63
+ }
64
+
65
+ // Plain text → default project
66
+ return {
67
+ type: 'task',
68
+ project: 'default',
69
+ branch: null,
70
+ text: trimmed,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Parse @project or @project/branch from command args.
76
+ * Returns { project, branch } or null.
77
+ */
78
+ export function parseTarget (args) {
79
+ if (!args) {
80
+ return null;
81
+ }
82
+ const match = args.trim().match(/^@(\S+)/);
83
+ if (!match) {
84
+ return null;
85
+ }
86
+ const target = match[1];
87
+ const slashIndex = target.indexOf('/');
88
+ if (slashIndex > 0) {
89
+ return {
90
+ project: target.substring(0, slashIndex),
91
+ branch: target.substring(slashIndex + 1),
92
+ rest: args.trim().substring(match[0].length).trim(),
93
+ };
94
+ }
95
+ return {
96
+ project: target,
97
+ branch: null,
98
+ rest: args.trim().substring(match[0].length).trim(),
99
+ };
100
+ }
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'child_process';
4
+ import { EventEmitter } from 'events';
5
+
6
+ const DEFAULT_TIMEOUT = 600_000; // 10 minutes
7
+
8
+ /**
9
+ * Runs claude CLI tasks and emits events on completion.
10
+ */
11
+ export class TaskRunner extends EventEmitter {
12
+ constructor (logger, timeout) {
13
+ super();
14
+ this.logger = logger;
15
+ this.timeout = timeout || DEFAULT_TIMEOUT;
16
+ this.activeProcesses = new Map(); // workDir → { child, timer, task }
17
+ }
18
+
19
+ /**
20
+ * Run a task in a specific workDir.
21
+ * @param {string} workDir - Working directory
22
+ * @param {object} task - Task object { id, text, telegramMessageId, ... }
23
+ * @returns {object} task with pid
24
+ */
25
+ run (workDir, task) {
26
+ if (this.activeProcesses.has(workDir)) {
27
+ throw new Error(`Already running a task in ${workDir}`);
28
+ }
29
+
30
+ this.logger.info(`Running task "${task.text}" in ${workDir}`);
31
+
32
+ const args = ['-p', task.text, '--output-format', 'text'];
33
+ const child = spawn('claude', args, {
34
+ cwd: workDir,
35
+ stdio: ['ignore', 'pipe', 'pipe'],
36
+ windowsHide: true,
37
+ shell: process.platform === 'win32',
38
+ });
39
+
40
+ let stdout = '';
41
+ let stderr = '';
42
+
43
+ child.stdout.on('data', (chunk) => {
44
+ stdout += chunk.toString();
45
+ });
46
+
47
+ child.stderr.on('data', (chunk) => {
48
+ stderr += chunk.toString();
49
+ });
50
+
51
+ const timer = setTimeout(() => {
52
+ this.logger.warn(`Task "${task.id}" timed out in ${workDir}`);
53
+ this._killProcess(workDir);
54
+ this.emit('timeout', workDir, task);
55
+ }, this.timeout);
56
+
57
+ child.on('close', (code) => {
58
+ clearTimeout(timer);
59
+ this.activeProcesses.delete(workDir);
60
+
61
+ if (code === null) {
62
+ // Process was killed (timeout or cancel)
63
+ return;
64
+ }
65
+
66
+ if (code === 0) {
67
+ this.logger.info(`Task "${task.id}" completed in ${workDir}`);
68
+ this.emit('complete', workDir, task, stdout.trim());
69
+ } else {
70
+ const errorMsg = stderr.trim() || `Process exited with code ${code}`;
71
+ this.logger.error(`Task "${task.id}" failed in ${workDir}: ${errorMsg}`);
72
+ this.emit('error', workDir, task, errorMsg);
73
+ }
74
+ });
75
+
76
+ child.on('error', (err) => {
77
+ clearTimeout(timer);
78
+ this.activeProcesses.delete(workDir);
79
+ this.logger.error(`Task "${task.id}" spawn error: ${err.message}`);
80
+ this.emit('error', workDir, task, err.message);
81
+ });
82
+
83
+ task.pid = child.pid;
84
+ task.startedAt = new Date().toISOString();
85
+ this.activeProcesses.set(workDir, { child, timer, task });
86
+
87
+ return task;
88
+ }
89
+
90
+ /**
91
+ * Cancel the active task in a workDir.
92
+ */
93
+ cancel (workDir) {
94
+ this._killProcess(workDir);
95
+ }
96
+
97
+ /**
98
+ * Check if a task is running in a workDir.
99
+ */
100
+ isRunning (workDir) {
101
+ return this.activeProcesses.has(workDir);
102
+ }
103
+
104
+ /**
105
+ * Get active task info for a workDir.
106
+ */
107
+ getActive (workDir) {
108
+ const entry = this.activeProcesses.get(workDir);
109
+ return entry?.task || null;
110
+ }
111
+
112
+ /**
113
+ * Cancel all active tasks (for graceful shutdown).
114
+ */
115
+ cancelAll () {
116
+ for (const workDir of this.activeProcesses.keys()) {
117
+ this._killProcess(workDir);
118
+ }
119
+ }
120
+
121
+ _killProcess (workDir) {
122
+ const entry = this.activeProcesses.get(workDir);
123
+ if (!entry) {
124
+ return;
125
+ }
126
+ clearTimeout(entry.timer);
127
+ try {
128
+ if (process.platform === 'win32') {
129
+ spawn('taskkill', ['/PID', String(entry.child.pid), '/T', '/F'], {
130
+ stdio: 'ignore',
131
+ windowsHide: true,
132
+ });
133
+ } else {
134
+ entry.child.kill('SIGTERM');
135
+ setTimeout(() => {
136
+ try {
137
+ entry.child.kill('SIGKILL');
138
+ } catch {
139
+ // already dead
140
+ }
141
+ }, 3000);
142
+ }
143
+ } catch {
144
+ // process already dead
145
+ }
146
+ this.activeProcesses.delete(workDir);
147
+ }
148
+ }
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+
3
+ const POLL_TIMEOUT = 30; // seconds
4
+ const MAX_MESSAGE_LENGTH = 4096;
5
+
6
+ export class TelegramPoller {
7
+ constructor (token, chatId, logger) {
8
+ this.token = token;
9
+ this.chatId = String(chatId);
10
+ this.logger = logger;
11
+ this.baseUrl = `https://api.telegram.org/bot${token}`;
12
+ this.offset = 0;
13
+ }
14
+
15
+ async getUpdates () {
16
+ try {
17
+ const url = `${this.baseUrl}/getUpdates?offset=${this.offset}&timeout=${POLL_TIMEOUT}&allowed_updates=["message"]`;
18
+ const res = await fetch(url, { signal: AbortSignal.timeout((POLL_TIMEOUT + 10) * 1000) });
19
+ const data = await res.json();
20
+ if (!data.ok) {
21
+ this.logger.error(`getUpdates failed: ${JSON.stringify(data)}`);
22
+ return [];
23
+ }
24
+ const messages = [];
25
+ for (const update of data.result || []) {
26
+ this.offset = update.update_id + 1;
27
+ const msg = update.message;
28
+ if (!msg || !msg.text) {
29
+ continue;
30
+ }
31
+ if (String(msg.chat.id) !== this.chatId) {
32
+ this.logger.warn(`Ignored message from chat ${msg.chat.id} (expected ${this.chatId})`);
33
+ continue;
34
+ }
35
+ messages.push({
36
+ messageId: msg.message_id,
37
+ text: msg.text,
38
+ chatId: msg.chat.id,
39
+ date: msg.date,
40
+ });
41
+ }
42
+ return messages;
43
+ } catch (err) {
44
+ if (err.name !== 'TimeoutError' && err.name !== 'AbortError') {
45
+ this.logger.error(`getUpdates error: ${err.message}`);
46
+ }
47
+ return [];
48
+ }
49
+ }
50
+
51
+ async sendMessage (text, replyToMessageId) {
52
+ const chunks = splitMessage(text);
53
+ for (const chunk of chunks) {
54
+ try {
55
+ const body = {
56
+ chat_id: this.chatId,
57
+ text: chunk,
58
+ parse_mode: 'HTML',
59
+ };
60
+ if (replyToMessageId) {
61
+ body.reply_to_message_id = replyToMessageId;
62
+ }
63
+ const res = await fetch(`${this.baseUrl}/sendMessage`, {
64
+ method: 'POST',
65
+ headers: { 'Content-Type': 'application/json' },
66
+ body: JSON.stringify(body),
67
+ });
68
+ const data = await res.json();
69
+ if (!data.ok) {
70
+ // Retry without HTML parse mode
71
+ await fetch(`${this.baseUrl}/sendMessage`, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({
75
+ chat_id: this.chatId,
76
+ text: chunk,
77
+ ...(replyToMessageId ? { reply_to_message_id: replyToMessageId } : {}),
78
+ }),
79
+ });
80
+ }
81
+ } catch (err) {
82
+ this.logger.error(`sendMessage error: ${err.message}`);
83
+ }
84
+ }
85
+ }
86
+
87
+ async sendDocument (buffer, filename, caption) {
88
+ try {
89
+ const formData = new FormData();
90
+ formData.append('chat_id', this.chatId);
91
+ formData.append('document', new Blob([buffer]), filename);
92
+ if (caption) {
93
+ formData.append('caption', caption.slice(0, 1024));
94
+ }
95
+ await fetch(`${this.baseUrl}/sendDocument`, {
96
+ method: 'POST',
97
+ body: formData,
98
+ });
99
+ } catch (err) {
100
+ this.logger.error(`sendDocument error: ${err.message}`);
101
+ }
102
+ }
103
+ }
104
+
105
+ function escapeHtml (text) {
106
+ return text
107
+ .replace(/&/g, '&')
108
+ .replace(/</g, '&lt;')
109
+ .replace(/>/g, '&gt;');
110
+ }
111
+
112
+ function splitMessage (text) {
113
+ if (text.length <= MAX_MESSAGE_LENGTH) {
114
+ return [text];
115
+ }
116
+
117
+ // For very long messages, send summary + file
118
+ if (text.length > 20000) {
119
+ const head = text.slice(0, 2000);
120
+ const tail = text.slice(-2000);
121
+ return [`${head}\n\n<i>... (truncated ${text.length} chars) ...</i>\n\n${tail}`];
122
+ }
123
+
124
+ // Split into chunks preserving line boundaries
125
+ const chunks = [];
126
+ let remaining = text;
127
+ while (remaining.length > 0) {
128
+ if (remaining.length <= MAX_MESSAGE_LENGTH) {
129
+ chunks.push(remaining);
130
+ break;
131
+ }
132
+ let splitAt = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
133
+ if (splitAt <= 0) {
134
+ splitAt = MAX_MESSAGE_LENGTH;
135
+ }
136
+ chunks.push(remaining.slice(0, splitAt));
137
+ remaining = remaining.slice(splitAt + 1);
138
+ }
139
+ return chunks;
140
+ }
141
+
142
+ export { escapeHtml };
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+
7
+ const QUEUE_FILE = path.join(os.homedir(), '.claude', '.task_queues.json');
8
+ const HISTORY_FILE = path.join(os.homedir(), '.claude', '.task_history.json');
9
+ const MAX_HISTORY = 50;
10
+
11
+ let idCounter = 0;
12
+
13
+ function generateId () {
14
+ return `task_${Date.now()}_${(++idCounter).toString(36)}`;
15
+ }
16
+
17
+ /**
18
+ * Per-workDir task queue with FIFO ordering and single-active-task lock.
19
+ */
20
+ export class WorkQueue {
21
+ constructor (logger, maxQueuePerWorkDir = 10, maxTotalTasks = 50) {
22
+ this.logger = logger;
23
+ this.maxQueuePerWorkDir = maxQueuePerWorkDir;
24
+ this.maxTotalTasks = maxTotalTasks;
25
+ this.queues = {}; // workDir → { project, branch, active, queue }
26
+ this._load();
27
+ }
28
+
29
+ /**
30
+ * Enqueue a task for a workDir. Returns the task object.
31
+ * If no active task, marks it as ready to run immediately.
32
+ */
33
+ enqueue (workDir, project, branch, text, telegramMessageId) {
34
+ if (!this.queues[workDir]) {
35
+ this.queues[workDir] = {
36
+ project,
37
+ branch: branch || 'main',
38
+ active: null,
39
+ queue: [],
40
+ };
41
+ }
42
+
43
+ const entry = this.queues[workDir];
44
+ const totalPending = this._countTotal();
45
+
46
+ if (totalPending >= this.maxTotalTasks) {
47
+ return { error: `Total task limit reached (${this.maxTotalTasks})` };
48
+ }
49
+
50
+ if (entry.queue.length >= this.maxQueuePerWorkDir) {
51
+ return { error: `Queue limit reached for this workDir (${this.maxQueuePerWorkDir})` };
52
+ }
53
+
54
+ const task = {
55
+ id: generateId(),
56
+ text,
57
+ telegramMessageId,
58
+ addedAt: new Date().toISOString(),
59
+ };
60
+
61
+ if (!entry.active) {
62
+ // Ready to run immediately
63
+ entry.active = task;
64
+ this._save();
65
+ return { task, position: 0, immediate: true };
66
+ }
67
+
68
+ // Add to queue
69
+ entry.queue.push(task);
70
+ this._save();
71
+ return {
72
+ task,
73
+ position: entry.queue.length,
74
+ immediate: false,
75
+ activeTask: entry.active,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Mark active task as started (update PID and startedAt).
81
+ */
82
+ markStarted (workDir, pid) {
83
+ const entry = this.queues[workDir];
84
+ if (entry?.active) {
85
+ entry.active.pid = pid;
86
+ entry.active.startedAt = new Date().toISOString();
87
+ this._save();
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Complete the active task. Returns next task if available, or null.
93
+ */
94
+ onTaskComplete (workDir, result) {
95
+ const entry = this.queues[workDir];
96
+ if (!entry) {
97
+ return null;
98
+ }
99
+
100
+ // Record in history
101
+ if (entry.active) {
102
+ this._addHistory({
103
+ ...entry.active,
104
+ project: entry.project,
105
+ branch: entry.branch,
106
+ workDir,
107
+ completedAt: new Date().toISOString(),
108
+ result: result ? result.slice(0, 500) : '',
109
+ });
110
+ }
111
+
112
+ // Get next task
113
+ if (entry.queue.length > 0) {
114
+ entry.active = entry.queue.shift();
115
+ this._save();
116
+ return entry.active;
117
+ }
118
+
119
+ entry.active = null;
120
+ this._save();
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Cancel the active task in a workDir. Returns next task if available.
126
+ */
127
+ cancelActive (workDir) {
128
+ const entry = this.queues[workDir];
129
+ if (!entry?.active) {
130
+ return null;
131
+ }
132
+
133
+ this._addHistory({
134
+ ...entry.active,
135
+ project: entry.project,
136
+ branch: entry.branch,
137
+ workDir,
138
+ completedAt: new Date().toISOString(),
139
+ result: 'CANCELLED',
140
+ });
141
+
142
+ if (entry.queue.length > 0) {
143
+ entry.active = entry.queue.shift();
144
+ this._save();
145
+ return entry.active;
146
+ }
147
+
148
+ entry.active = null;
149
+ this._save();
150
+ return null;
151
+ }
152
+
153
+ /**
154
+ * Remove a task from the queue by index (1-based).
155
+ */
156
+ removeFromQueue (workDir, index) {
157
+ const entry = this.queues[workDir];
158
+ if (!entry) {
159
+ return null;
160
+ }
161
+ const idx = index - 1;
162
+ if (idx < 0 || idx >= entry.queue.length) {
163
+ return null;
164
+ }
165
+ const removed = entry.queue.splice(idx, 1)[0];
166
+ this._save();
167
+ return removed;
168
+ }
169
+
170
+ /**
171
+ * Clear all queued (non-active) tasks for a workDir.
172
+ */
173
+ clearQueue (workDir) {
174
+ const entry = this.queues[workDir];
175
+ if (!entry) {
176
+ return 0;
177
+ }
178
+ const count = entry.queue.length;
179
+ entry.queue = [];
180
+ this._save();
181
+ return count;
182
+ }
183
+
184
+ /**
185
+ * Get status for a project (all workDirs).
186
+ */
187
+ getProjectStatus (projectAlias) {
188
+ const results = [];
189
+ for (const [workDir, entry] of Object.entries(this.queues)) {
190
+ if (entry.project === projectAlias) {
191
+ results.push({
192
+ workDir,
193
+ branch: entry.branch,
194
+ active: entry.active,
195
+ queueLength: entry.queue.length,
196
+ queue: entry.queue,
197
+ });
198
+ }
199
+ }
200
+ return results;
201
+ }
202
+
203
+ /**
204
+ * Get status for all projects.
205
+ */
206
+ getAllStatus () {
207
+ const results = {};
208
+ for (const [workDir, entry] of Object.entries(this.queues)) {
209
+ if (!results[entry.project]) {
210
+ results[entry.project] = [];
211
+ }
212
+ results[entry.project].push({
213
+ workDir,
214
+ branch: entry.branch,
215
+ active: entry.active,
216
+ queueLength: entry.queue.length,
217
+ });
218
+ }
219
+ return results;
220
+ }
221
+
222
+ /**
223
+ * Get recent task history.
224
+ */
225
+ getHistory (limit = 10) {
226
+ return this._loadHistory().slice(-limit);
227
+ }
228
+
229
+ /**
230
+ * Watchdog: clean up stale active tasks (dead PIDs, expired timeouts).
231
+ */
232
+ watchdog (taskTimeout) {
233
+ const now = Date.now();
234
+ const recovered = [];
235
+ for (const [workDir, entry] of Object.entries(this.queues)) {
236
+ if (!entry.active) {
237
+ continue;
238
+ }
239
+ const startedAt = entry.active.startedAt ? new Date(entry.active.startedAt).getTime() : 0;
240
+ const isStale = startedAt > 0 && (now - startedAt) > taskTimeout;
241
+
242
+ if (isStale) {
243
+ this.logger.warn(`Watchdog: stale task "${entry.active.id}" in ${workDir}`);
244
+ const next = this.onTaskComplete(workDir, 'STALE (watchdog cleanup)');
245
+ recovered.push({ workDir, next });
246
+ }
247
+ }
248
+ return recovered;
249
+ }
250
+
251
+ _countTotal () {
252
+ let count = 0;
253
+ for (const entry of Object.values(this.queues)) {
254
+ if (entry.active) {
255
+ count++;
256
+ }
257
+ count += entry.queue.length;
258
+ }
259
+ return count;
260
+ }
261
+
262
+ _load () {
263
+ try {
264
+ if (fs.existsSync(QUEUE_FILE)) {
265
+ this.queues = JSON.parse(fs.readFileSync(QUEUE_FILE, 'utf-8'));
266
+ }
267
+ } catch (err) {
268
+ this.logger.error(`Failed to load queue file: ${err.message}`);
269
+ this.queues = {};
270
+ }
271
+ }
272
+
273
+ _save () {
274
+ try {
275
+ const dir = path.dirname(QUEUE_FILE);
276
+ fs.mkdirSync(dir, { recursive: true });
277
+ fs.writeFileSync(QUEUE_FILE, JSON.stringify(this.queues, null, 2));
278
+ } catch (err) {
279
+ this.logger.error(`Failed to save queue file: ${err.message}`);
280
+ }
281
+ }
282
+
283
+ _addHistory (entry) {
284
+ const history = this._loadHistory();
285
+ history.push(entry);
286
+ while (history.length > MAX_HISTORY) {
287
+ history.shift();
288
+ }
289
+ try {
290
+ fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
291
+ } catch {
292
+ // ignore
293
+ }
294
+ }
295
+
296
+ _loadHistory () {
297
+ try {
298
+ if (fs.existsSync(HISTORY_FILE)) {
299
+ return JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'));
300
+ }
301
+ } catch {
302
+ // ignore
303
+ }
304
+ return [];
305
+ }
306
+ }