claude-notification-plugin 1.0.59 → 1.0.65
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 +261 -208
- package/bin/listener-cli.js +255 -0
- package/commands/listener.md +100 -0
- package/listener/listener.js +613 -0
- package/listener/logger.js +46 -0
- package/listener/message-parser.js +100 -0
- package/listener/task-runner.js +148 -0
- package/listener/telegram-poller.js +142 -0
- package/listener/work-queue.js +306 -0
- package/listener/worktree-manager.js +279 -0
- package/notifier/notifier.js +4 -2
- package/package.json +4 -2
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import process from 'process';
|
|
7
|
+
import { createLogger } from './logger.js';
|
|
8
|
+
import { TelegramPoller, escapeHtml } from './telegram-poller.js';
|
|
9
|
+
import { WorkQueue } from './work-queue.js';
|
|
10
|
+
import { TaskRunner } from './task-runner.js';
|
|
11
|
+
import { WorktreeManager } from './worktree-manager.js';
|
|
12
|
+
import { parseMessage, parseTarget } from './message-parser.js';
|
|
13
|
+
|
|
14
|
+
// ----------------------
|
|
15
|
+
// CONFIG
|
|
16
|
+
// ----------------------
|
|
17
|
+
|
|
18
|
+
const CONFIG_PATH = path.join(os.homedir(), '.claude', 'notifier.config.json');
|
|
19
|
+
const LOG_PATH = path.join(os.homedir(), '.claude', '.listener.log');
|
|
20
|
+
|
|
21
|
+
function loadConfig () {
|
|
22
|
+
try {
|
|
23
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
|
24
|
+
return JSON.parse(raw);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.error(`Failed to load config from ${CONFIG_PATH}: ${err.message}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ----------------------
|
|
32
|
+
// MAIN DAEMON
|
|
33
|
+
// ----------------------
|
|
34
|
+
|
|
35
|
+
const config = loadConfig();
|
|
36
|
+
const logger = createLogger(LOG_PATH);
|
|
37
|
+
|
|
38
|
+
// Validate required fields
|
|
39
|
+
const token = process.env.CLAUDE_NOTIFY_TELEGRAM_TOKEN || config.telegramToken || config.telegram?.token;
|
|
40
|
+
const chatId = process.env.CLAUDE_NOTIFY_TELEGRAM_CHAT_ID || config.telegramChatId || config.telegram?.chatId;
|
|
41
|
+
|
|
42
|
+
if (!token || !chatId) {
|
|
43
|
+
logger.error('Missing telegramToken or telegramChatId in config');
|
|
44
|
+
console.error('Missing telegramToken or telegramChatId in config');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!config.listener?.projects || Object.keys(config.listener.projects).length === 0) {
|
|
49
|
+
logger.error('No projects defined in config.listener.projects');
|
|
50
|
+
console.error('No projects defined in config.listener.projects');
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const listenerConfig = config.listener;
|
|
55
|
+
const taskTimeout = listenerConfig.taskTimeout || 600_000;
|
|
56
|
+
|
|
57
|
+
const poller = new TelegramPoller(token, chatId, logger);
|
|
58
|
+
const queue = new WorkQueue(
|
|
59
|
+
logger,
|
|
60
|
+
listenerConfig.maxQueuePerWorkDir || 10,
|
|
61
|
+
listenerConfig.maxTotalTasks || 50,
|
|
62
|
+
);
|
|
63
|
+
const runner = new TaskRunner(logger, taskTimeout);
|
|
64
|
+
const worktreeManager = new WorktreeManager(config, logger);
|
|
65
|
+
|
|
66
|
+
const startTime = Date.now();
|
|
67
|
+
|
|
68
|
+
logger.info('Listener started');
|
|
69
|
+
logger.info(`Projects: ${JSON.stringify(Object.keys(listenerConfig.projects))}`);
|
|
70
|
+
|
|
71
|
+
// ----------------------
|
|
72
|
+
// DISCOVER WORKTREES ON START
|
|
73
|
+
// ----------------------
|
|
74
|
+
|
|
75
|
+
for (const alias of Object.keys(listenerConfig.projects)) {
|
|
76
|
+
worktreeManager.discoverWorktrees(alias);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ----------------------
|
|
80
|
+
// WATCHDOG
|
|
81
|
+
// ----------------------
|
|
82
|
+
|
|
83
|
+
const recovered = queue.watchdog(taskTimeout);
|
|
84
|
+
for (const { workDir, next } of recovered) {
|
|
85
|
+
if (next) {
|
|
86
|
+
startTask(workDir, next);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ----------------------
|
|
91
|
+
// TASK RUNNER EVENTS
|
|
92
|
+
// ----------------------
|
|
93
|
+
|
|
94
|
+
runner.on('complete', async (workDir, task, output) => {
|
|
95
|
+
const entry = queue.queues[workDir];
|
|
96
|
+
const label = formatLabel(entry);
|
|
97
|
+
|
|
98
|
+
// Send result
|
|
99
|
+
let text = `✅ [${label}] Done: ${escapeHtml(task.text)}`;
|
|
100
|
+
if (output) {
|
|
101
|
+
if (output.length > 20000) {
|
|
102
|
+
const head = output.slice(0, 2000);
|
|
103
|
+
const tail = output.slice(-2000);
|
|
104
|
+
text += `\n\n<pre>${escapeHtml(head)}\n\n... (${output.length} chars) ...\n\n${escapeHtml(tail)}</pre>`;
|
|
105
|
+
// Also send as file
|
|
106
|
+
await poller.sendDocument(
|
|
107
|
+
Buffer.from(output, 'utf-8'),
|
|
108
|
+
`result_${task.id}.txt`,
|
|
109
|
+
`Full output for: ${task.text.slice(0, 100)}`
|
|
110
|
+
);
|
|
111
|
+
} else {
|
|
112
|
+
text += `\n\n<pre>${escapeHtml(output)}</pre>`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await poller.sendMessage(text, task.telegramMessageId);
|
|
116
|
+
|
|
117
|
+
// Process next in queue
|
|
118
|
+
const next = queue.onTaskComplete(workDir, output);
|
|
119
|
+
if (next) {
|
|
120
|
+
startTask(workDir, next);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
runner.on('error', async (workDir, task, errorMsg) => {
|
|
125
|
+
const entry = queue.queues[workDir];
|
|
126
|
+
const label = formatLabel(entry);
|
|
127
|
+
await poller.sendMessage(
|
|
128
|
+
`❌ [${label}] Error: ${escapeHtml(task.text)}\n\n<pre>${escapeHtml(errorMsg)}</pre>`,
|
|
129
|
+
task.telegramMessageId,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const next = queue.onTaskComplete(workDir, `ERROR: ${errorMsg}`);
|
|
133
|
+
if (next) {
|
|
134
|
+
startTask(workDir, next);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
runner.on('timeout', async (workDir, task) => {
|
|
139
|
+
const entry = queue.queues[workDir];
|
|
140
|
+
const label = formatLabel(entry);
|
|
141
|
+
await poller.sendMessage(
|
|
142
|
+
`⏰ [${label}] Timeout (${Math.round(taskTimeout / 60000)} min): ${escapeHtml(task.text)}`,
|
|
143
|
+
task.telegramMessageId,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const next = queue.onTaskComplete(workDir, 'TIMEOUT');
|
|
147
|
+
if (next) {
|
|
148
|
+
startTask(workDir, next);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ----------------------
|
|
153
|
+
// HELPERS
|
|
154
|
+
// ----------------------
|
|
155
|
+
|
|
156
|
+
function formatLabel (entry) {
|
|
157
|
+
if (!entry) {
|
|
158
|
+
return 'unknown';
|
|
159
|
+
}
|
|
160
|
+
if (entry.branch && entry.branch !== 'main' && entry.branch !== 'master') {
|
|
161
|
+
return `@${entry.project}/${entry.branch}`;
|
|
162
|
+
}
|
|
163
|
+
return `@${entry.project}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function startTask (workDir, task) {
|
|
167
|
+
const entry = queue.queues[workDir];
|
|
168
|
+
const label = formatLabel(entry);
|
|
169
|
+
poller.sendMessage(`⏳ [${label}] Running: ${escapeHtml(task.text)}`, task.telegramMessageId);
|
|
170
|
+
try {
|
|
171
|
+
const started = runner.run(workDir, task);
|
|
172
|
+
queue.markStarted(workDir, started.pid);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
logger.error(`Failed to start task: ${err.message}`);
|
|
175
|
+
poller.sendMessage(`❌ [${label}] Failed to start: ${escapeHtml(err.message)}`);
|
|
176
|
+
queue.onTaskComplete(workDir, `START_ERROR: ${err.message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function formatDuration (ms) {
|
|
181
|
+
const sec = Math.floor(ms / 1000);
|
|
182
|
+
if (sec < 60) {
|
|
183
|
+
return `${sec}s`;
|
|
184
|
+
}
|
|
185
|
+
return `${Math.floor(sec / 60)}m ${sec % 60}s`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ----------------------
|
|
189
|
+
// COMMAND HANDLERS
|
|
190
|
+
// ----------------------
|
|
191
|
+
|
|
192
|
+
async function handleCommand (cmd, args) {
|
|
193
|
+
switch (cmd) {
|
|
194
|
+
case '/status':
|
|
195
|
+
return handleStatus(args);
|
|
196
|
+
case '/queue':
|
|
197
|
+
return handleQueue();
|
|
198
|
+
case '/cancel':
|
|
199
|
+
return handleCancel(args);
|
|
200
|
+
case '/drop':
|
|
201
|
+
return handleDrop(args);
|
|
202
|
+
case '/clear':
|
|
203
|
+
return handleClear(args);
|
|
204
|
+
case '/projects':
|
|
205
|
+
return handleProjects();
|
|
206
|
+
case '/worktrees':
|
|
207
|
+
return handleWorktrees(args);
|
|
208
|
+
case '/worktree':
|
|
209
|
+
return handleCreateWorktree(args);
|
|
210
|
+
case '/rmworktree':
|
|
211
|
+
return handleRemoveWorktree(args);
|
|
212
|
+
case '/history':
|
|
213
|
+
return handleHistory();
|
|
214
|
+
case '/stop':
|
|
215
|
+
return handleStop();
|
|
216
|
+
case '/help':
|
|
217
|
+
return handleHelp();
|
|
218
|
+
default:
|
|
219
|
+
return `Unknown command: ${cmd}`;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function handleStatus (args) {
|
|
224
|
+
const target = parseTarget(args);
|
|
225
|
+
|
|
226
|
+
if (target) {
|
|
227
|
+
const statuses = queue.getProjectStatus(target.project);
|
|
228
|
+
if (statuses.length === 0) {
|
|
229
|
+
return `📊 Project "${target.project}": no active queues`;
|
|
230
|
+
}
|
|
231
|
+
let text = `📊 Project "<b>${escapeHtml(target.project)}</b>":\n`;
|
|
232
|
+
for (const s of statuses) {
|
|
233
|
+
const branchLabel = s.branch || 'main';
|
|
234
|
+
if (s.active) {
|
|
235
|
+
const elapsed = s.active.startedAt
|
|
236
|
+
? formatDuration(Date.now() - new Date(s.active.startedAt).getTime())
|
|
237
|
+
: '?';
|
|
238
|
+
text += `\n<b>${escapeHtml(branchLabel)}</b>:\n`;
|
|
239
|
+
text += ` ▶ ${escapeHtml(s.active.text)} (${elapsed})\n`;
|
|
240
|
+
text += ` Queue: ${s.queueLength} tasks\n`;
|
|
241
|
+
} else {
|
|
242
|
+
text += `\n<b>${escapeHtml(branchLabel)}</b>: ✅ idle\n`;
|
|
243
|
+
text += ` Queue: ${s.queueLength} tasks\n`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return text;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// All projects
|
|
250
|
+
const all = queue.getAllStatus();
|
|
251
|
+
if (Object.keys(all).length === 0) {
|
|
252
|
+
const uptime = formatDuration(Date.now() - startTime);
|
|
253
|
+
return `📊 No active tasks\nUptime: ${uptime}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let text = '📊 <b>Status:</b>\n';
|
|
257
|
+
const uptime = formatDuration(Date.now() - startTime);
|
|
258
|
+
text += `Uptime: ${uptime}\n`;
|
|
259
|
+
for (const [project, statuses] of Object.entries(all)) {
|
|
260
|
+
text += `\n<b>${escapeHtml(project)}</b>:`;
|
|
261
|
+
for (const s of statuses) {
|
|
262
|
+
const branchLabel = s.branch || 'main';
|
|
263
|
+
if (s.active) {
|
|
264
|
+
const elapsed = s.active.startedAt
|
|
265
|
+
? formatDuration(Date.now() - new Date(s.active.startedAt).getTime())
|
|
266
|
+
: '?';
|
|
267
|
+
text += `\n ${escapeHtml(branchLabel)}: ▶ ${escapeHtml(s.active.text)} (${elapsed})`;
|
|
268
|
+
if (s.queueLength > 0) {
|
|
269
|
+
text += ` +${s.queueLength} queued`;
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
text += `\n ${escapeHtml(branchLabel)}: ✅ idle`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return text;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function handleQueue () {
|
|
280
|
+
const all = queue.getAllStatus();
|
|
281
|
+
if (Object.keys(all).length === 0) {
|
|
282
|
+
return '📋 All queues are empty';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let text = '📋 <b>Queues:</b>\n';
|
|
286
|
+
for (const [project, statuses] of Object.entries(all)) {
|
|
287
|
+
for (const s of statuses) {
|
|
288
|
+
const label = s.branch && s.branch !== 'main' && s.branch !== 'master'
|
|
289
|
+
? `@${project}/${s.branch}`
|
|
290
|
+
: `@${project}`;
|
|
291
|
+
if (s.active || s.queueLength > 0) {
|
|
292
|
+
text += `\n<b>${escapeHtml(label)}</b>:`;
|
|
293
|
+
if (s.active) {
|
|
294
|
+
text += `\n ▶ ${escapeHtml(s.active.text)}`;
|
|
295
|
+
}
|
|
296
|
+
const entry = queue.queues[Object.keys(queue.queues).find(
|
|
297
|
+
(wd) => queue.queues[wd].project === project && queue.queues[wd].branch === s.branch
|
|
298
|
+
)];
|
|
299
|
+
if (entry?.queue) {
|
|
300
|
+
for (let i = 0; i < entry.queue.length; i++) {
|
|
301
|
+
text += `\n ${i + 1}. ${escapeHtml(entry.queue[i].text)}`;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return text;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function handleCancel (args) {
|
|
311
|
+
const target = parseTarget(args);
|
|
312
|
+
const projectAlias = target?.project || 'default';
|
|
313
|
+
const branch = target?.branch || null;
|
|
314
|
+
|
|
315
|
+
let workDir;
|
|
316
|
+
try {
|
|
317
|
+
workDir = worktreeManager.resolveWorkDir(projectAlias, branch);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
return `❌ ${escapeHtml(err.message)}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!runner.isRunning(workDir)) {
|
|
323
|
+
return `❌ No active task in @${escapeHtml(projectAlias)}${branch ? '/' + escapeHtml(branch) : ''}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
runner.cancel(workDir);
|
|
327
|
+
const next = queue.cancelActive(workDir);
|
|
328
|
+
const label = branch ? `@${projectAlias}/${branch}` : `@${projectAlias}`;
|
|
329
|
+
|
|
330
|
+
if (next) {
|
|
331
|
+
startTask(workDir, next);
|
|
332
|
+
return `🛑 [${escapeHtml(label)}] Task cancelled. Starting next.`;
|
|
333
|
+
}
|
|
334
|
+
return `🛑 [${escapeHtml(label)}] Task cancelled`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function handleDrop (args) {
|
|
338
|
+
const target = parseTarget(args);
|
|
339
|
+
if (!target) {
|
|
340
|
+
return '❌ Usage: /drop @project N';
|
|
341
|
+
}
|
|
342
|
+
const index = parseInt(target.rest, 10);
|
|
343
|
+
if (!index || index < 1) {
|
|
344
|
+
return '❌ Specify task number (starting from 1)';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
let workDir;
|
|
348
|
+
try {
|
|
349
|
+
workDir = worktreeManager.resolveWorkDir(target.project, target.branch);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
return `❌ ${escapeHtml(err.message)}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const removed = queue.removeFromQueue(workDir, index);
|
|
355
|
+
if (!removed) {
|
|
356
|
+
return `❌ Task #${index} not found in queue`;
|
|
357
|
+
}
|
|
358
|
+
return `🗑 Removed from queue: ${escapeHtml(removed.text)}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function handleClear (args) {
|
|
362
|
+
const target = parseTarget(args);
|
|
363
|
+
const projectAlias = target?.project || 'default';
|
|
364
|
+
const branch = target?.branch || null;
|
|
365
|
+
|
|
366
|
+
let workDir;
|
|
367
|
+
try {
|
|
368
|
+
workDir = worktreeManager.resolveWorkDir(projectAlias, branch);
|
|
369
|
+
} catch (err) {
|
|
370
|
+
return `❌ ${escapeHtml(err.message)}`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const count = queue.clearQueue(workDir);
|
|
374
|
+
const label = branch ? `@${projectAlias}/${branch}` : `@${projectAlias}`;
|
|
375
|
+
return `🧹 [${escapeHtml(label)}] Queue cleared (${count} tasks)`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function handleProjects () {
|
|
379
|
+
const projects = listenerConfig.projects;
|
|
380
|
+
let text = '📂 <b>Projects:</b>\n';
|
|
381
|
+
for (const [alias, proj] of Object.entries(projects)) {
|
|
382
|
+
const projPath = typeof proj === 'string' ? proj : proj.path;
|
|
383
|
+
text += `\n<b>@${escapeHtml(alias)}</b> → <code>${escapeHtml(projPath)}</code>`;
|
|
384
|
+
const worktrees = typeof proj === 'object' ? proj.worktrees : null;
|
|
385
|
+
if (worktrees && Object.keys(worktrees).length > 0) {
|
|
386
|
+
for (const [branch, wtPath] of Object.entries(worktrees)) {
|
|
387
|
+
text += `\n /${escapeHtml(branch)} → <code>${escapeHtml(wtPath)}</code>`;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return text;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function handleWorktrees (args) {
|
|
395
|
+
const target = parseTarget(args);
|
|
396
|
+
if (!target) {
|
|
397
|
+
return '❌ Usage: /worktrees @project';
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const result = worktreeManager.listWorktrees(target.project);
|
|
401
|
+
if (!result) {
|
|
402
|
+
return `❌ Project "${escapeHtml(target.project)}" not found`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let text = `🌳 Worktrees for "<b>${escapeHtml(target.project)}</b>":\n`;
|
|
406
|
+
text += `\n• <b>main</b> → <code>${escapeHtml(result.main)}</code>`;
|
|
407
|
+
for (const [branch, wtPath] of Object.entries(result.worktrees)) {
|
|
408
|
+
text += `\n• <b>${escapeHtml(branch)}</b> → <code>${escapeHtml(wtPath)}</code>`;
|
|
409
|
+
}
|
|
410
|
+
return text;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function handleCreateWorktree (args) {
|
|
414
|
+
const target = parseTarget(args);
|
|
415
|
+
if (!target || !target.rest) {
|
|
416
|
+
return '❌ Usage: /worktree @project branch-name';
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const branch = target.rest;
|
|
420
|
+
try {
|
|
421
|
+
const wtDir = worktreeManager.createWorktree(target.project, branch);
|
|
422
|
+
return `🌿 Created worktree for "<b>${escapeHtml(target.project)}</b>":\n`
|
|
423
|
+
+ `Branch: <b>${escapeHtml(branch)}</b>\n`
|
|
424
|
+
+ `Path: <code>${escapeHtml(wtDir)}</code>`;
|
|
425
|
+
} catch (err) {
|
|
426
|
+
return `❌ ${escapeHtml(err.message)}`;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function handleRemoveWorktree (args) {
|
|
431
|
+
const target = parseTarget(args);
|
|
432
|
+
if (!target || !target.rest) {
|
|
433
|
+
return '❌ Usage: /rmworktree @project branch-name';
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const branch = target.rest;
|
|
437
|
+
|
|
438
|
+
// Check if there's an active task in this worktree
|
|
439
|
+
let workDir;
|
|
440
|
+
try {
|
|
441
|
+
const project = listenerConfig.projects[target.project];
|
|
442
|
+
workDir = project?.worktrees?.[branch];
|
|
443
|
+
} catch {
|
|
444
|
+
// ignore
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (workDir && runner.isRunning(workDir)) {
|
|
448
|
+
return `❌ Cannot remove worktree: task is running. First /cancel @${escapeHtml(target.project)}/${escapeHtml(branch)}`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
worktreeManager.removeWorktree(target.project, branch);
|
|
453
|
+
return `🗑 Worktree <b>${escapeHtml(branch)}</b> removed from "<b>${escapeHtml(target.project)}</b>"`;
|
|
454
|
+
} catch (err) {
|
|
455
|
+
return `❌ ${escapeHtml(err.message)}`;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function handleHistory () {
|
|
460
|
+
const history = queue.getHistory(10);
|
|
461
|
+
if (history.length === 0) {
|
|
462
|
+
return '📜 History is empty';
|
|
463
|
+
}
|
|
464
|
+
let text = '📜 <b>Recent tasks:</b>\n';
|
|
465
|
+
for (const h of history.reverse()) {
|
|
466
|
+
const label = h.branch && h.branch !== 'main' && h.branch !== 'master'
|
|
467
|
+
? `@${h.project}/${h.branch}`
|
|
468
|
+
: `@${h.project}`;
|
|
469
|
+
const status = h.result === 'CANCELLED' ? '🛑' : h.result?.startsWith('ERROR') ? '❌' : '✅';
|
|
470
|
+
text += `\n${status} [${escapeHtml(label)}] ${escapeHtml(h.text)}`;
|
|
471
|
+
}
|
|
472
|
+
return text;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function handleStop () {
|
|
476
|
+
await poller.sendMessage('👋 Listener shutting down...');
|
|
477
|
+
runner.cancelAll();
|
|
478
|
+
logger.info('Graceful shutdown requested via /stop');
|
|
479
|
+
setTimeout(() => process.exit(0), 1000);
|
|
480
|
+
return null; // Message already sent
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function handleHelp () {
|
|
484
|
+
return '<b>📖 Commands:</b>\n'
|
|
485
|
+
+ '\n/status — status of all projects'
|
|
486
|
+
+ '\n/status @project — project status'
|
|
487
|
+
+ '\n/queue — all queues'
|
|
488
|
+
+ '\n/cancel [@project[/branch]] — cancel task'
|
|
489
|
+
+ '\n/drop @project N — remove task from queue'
|
|
490
|
+
+ '\n/clear @project[/branch] — clear queue'
|
|
491
|
+
+ '\n/projects — list projects'
|
|
492
|
+
+ '\n/worktrees @project — project worktrees'
|
|
493
|
+
+ '\n/worktree @project branch — create worktree'
|
|
494
|
+
+ '\n/rmworktree @project branch — remove worktree'
|
|
495
|
+
+ '\n/history — task history'
|
|
496
|
+
+ '\n/stop — stop listener'
|
|
497
|
+
+ '\n/help — this help'
|
|
498
|
+
+ '\n\n<b>Tasks:</b>'
|
|
499
|
+
+ '\n<code>@project task</code> — main worktree'
|
|
500
|
+
+ '\n<code>@project/branch task</code> — worktree'
|
|
501
|
+
+ '\n<code>task</code> — default project';
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ----------------------
|
|
505
|
+
// TASK HANDLER
|
|
506
|
+
// ----------------------
|
|
507
|
+
|
|
508
|
+
async function handleTask (parsed, telegramMessageId) {
|
|
509
|
+
let workDir;
|
|
510
|
+
let autoCreated = false;
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
const project = listenerConfig.projects[parsed.project];
|
|
514
|
+
if (!project) {
|
|
515
|
+
await poller.sendMessage(`❌ Project "<b>${escapeHtml(parsed.project)}</b>" not found. Use /projects to list.`);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Check if worktree needs auto-creation (for notification)
|
|
520
|
+
if (parsed.branch) {
|
|
521
|
+
const existing = typeof project === 'object' && project.worktrees?.[parsed.branch];
|
|
522
|
+
if (!existing) {
|
|
523
|
+
autoCreated = true;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
workDir = worktreeManager.resolveWorkDir(parsed.project, parsed.branch);
|
|
528
|
+
} catch (err) {
|
|
529
|
+
await poller.sendMessage(`❌ ${escapeHtml(err.message)}`);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (autoCreated) {
|
|
534
|
+
await poller.sendMessage(`🌿 Created worktree <b>${escapeHtml(parsed.branch)}</b> for "<b>${escapeHtml(parsed.project)}</b>"`);
|
|
535
|
+
logger.info(`Auto-created worktree for task: @${parsed.project}/${parsed.branch} → ${workDir}`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const result = queue.enqueue(
|
|
539
|
+
workDir,
|
|
540
|
+
parsed.project,
|
|
541
|
+
parsed.branch || 'main',
|
|
542
|
+
parsed.text,
|
|
543
|
+
telegramMessageId,
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
if (result.error) {
|
|
547
|
+
await poller.sendMessage(`❌ ${escapeHtml(result.error)}`);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (result.immediate) {
|
|
552
|
+
startTask(workDir, result.task);
|
|
553
|
+
} else {
|
|
554
|
+
const entry = queue.queues[workDir];
|
|
555
|
+
const label = formatLabel(entry);
|
|
556
|
+
await poller.sendMessage(
|
|
557
|
+
`📋 [${escapeHtml(label)}] Queued (position ${result.position}).\n`
|
|
558
|
+
+ `Currently running: ${escapeHtml(result.activeTask.text)}`,
|
|
559
|
+
telegramMessageId,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ----------------------
|
|
565
|
+
// MAIN LOOP
|
|
566
|
+
// ----------------------
|
|
567
|
+
|
|
568
|
+
let running = true;
|
|
569
|
+
|
|
570
|
+
process.on('SIGTERM', () => {
|
|
571
|
+
logger.info('Received SIGTERM');
|
|
572
|
+
running = false;
|
|
573
|
+
runner.cancelAll();
|
|
574
|
+
setTimeout(() => process.exit(0), 2000);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
process.on('SIGINT', () => {
|
|
578
|
+
logger.info('Received SIGINT');
|
|
579
|
+
running = false;
|
|
580
|
+
runner.cancelAll();
|
|
581
|
+
setTimeout(() => process.exit(0), 2000);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
async function mainLoop () {
|
|
585
|
+
while (running) {
|
|
586
|
+
try {
|
|
587
|
+
const messages = await poller.getUpdates();
|
|
588
|
+
for (const msg of messages) {
|
|
589
|
+
const parsed = parseMessage(msg.text);
|
|
590
|
+
if (!parsed) {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (parsed.type === 'command') {
|
|
595
|
+
logger.info(`Command: ${parsed.cmd} ${parsed.args}`);
|
|
596
|
+
const response = await handleCommand(parsed.cmd, parsed.args);
|
|
597
|
+
if (response) {
|
|
598
|
+
await poller.sendMessage(response, msg.messageId);
|
|
599
|
+
}
|
|
600
|
+
} else if (parsed.type === 'task') {
|
|
601
|
+
logger.info(`Task for @${parsed.project}${parsed.branch ? '/' + parsed.branch : ''}: ${parsed.text}`);
|
|
602
|
+
await handleTask(parsed, msg.messageId);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
} catch (err) {
|
|
606
|
+
logger.error(`Main loop error: ${err.message}`);
|
|
607
|
+
// Wait before retrying on error
|
|
608
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
mainLoop();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB
|
|
7
|
+
|
|
8
|
+
export function createLogger (logPath) {
|
|
9
|
+
const dir = path.dirname(logPath);
|
|
10
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
11
|
+
|
|
12
|
+
function rotateIfNeeded () {
|
|
13
|
+
try {
|
|
14
|
+
const stat = fs.statSync(logPath);
|
|
15
|
+
if (stat.size > MAX_LOG_SIZE) {
|
|
16
|
+
const backup = logPath + '.old';
|
|
17
|
+
if (fs.existsSync(backup)) {
|
|
18
|
+
fs.unlinkSync(backup);
|
|
19
|
+
}
|
|
20
|
+
fs.renameSync(logPath, backup);
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// file doesn't exist yet
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatLine (level, msg) {
|
|
28
|
+
const ts = new Date().toISOString();
|
|
29
|
+
return `[${ts}] [${level}] ${msg}\n`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
info (msg) {
|
|
34
|
+
rotateIfNeeded();
|
|
35
|
+
fs.appendFileSync(logPath, formatLine('INFO', msg));
|
|
36
|
+
},
|
|
37
|
+
error (msg) {
|
|
38
|
+
rotateIfNeeded();
|
|
39
|
+
fs.appendFileSync(logPath, formatLine('ERROR', msg));
|
|
40
|
+
},
|
|
41
|
+
warn (msg) {
|
|
42
|
+
rotateIfNeeded();
|
|
43
|
+
fs.appendFileSync(logPath, formatLine('WARN', msg));
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|