claude-nonstop 0.3.0

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,504 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Claude Code Hook Notification Script
5
+ *
6
+ * Called by Claude Code hooks (Stop, SessionStart, PostToolUse) and runner.js (account-switch).
7
+ *
8
+ * Notification types:
9
+ * session-start — Create per-session Slack channel (Claude Code hook)
10
+ * completed — Post structured completion message (Claude Code hook)
11
+ * tool-use — Buffer tool activity, flush to Slack every 10s (Claude Code PostToolUse hook)
12
+ * waiting-for-input — Notify when Claude is waiting for user input (Claude Code PreToolUse hook)
13
+ * account-switch — Notify about rate limit account switch (runner.js)
14
+ * sleep-until-reset — Notify that all accounts are near-exhausted, sleeping until reset (runner.js)
15
+ * sleep-wake — Notify that sleep is complete and resuming (runner.js)
16
+ *
17
+ * Environment:
18
+ * CLAUDE_REMOTE_ACCESS=true — enables per-session Slack channels
19
+ * SLACK_BOT_TOKEN — Slack bot token (xoxb-...)
20
+ * SLACK_CHANNEL_PREFIX — channel name prefix (default: 'cn')
21
+ * SLACK_INVITE_USER_ID — user to auto-invite to channels
22
+ */
23
+
24
+ const path = require('path');
25
+ const fs = require('fs');
26
+ const { execFileSync } = require('child_process');
27
+
28
+ require('./load-env.cjs');
29
+
30
+ const SlackChannelManager = require('./channel-manager.cjs');
31
+ const { markdownToMrkdwn } = SlackChannelManager;
32
+ const { PROGRESS_DIR } = require('./paths.cjs');
33
+
34
+ // ─── Progress Buffer Constants ──────────────────────────────────────────────
35
+
36
+ const FLUSH_INTERVAL_MS = 3_000;
37
+ const MAX_BUFFER_EVENTS = 100;
38
+
39
+ // Tools that pause Claude to wait for user input in the terminal
40
+ const WAITING_FOR_INPUT_TOOLS = new Set(['ExitPlanMode', 'AskUserQuestion']);
41
+
42
+ // ─── Stdin Reader ────────────────────────────────────────────────────────────
43
+
44
+ async function readStdin() {
45
+ return new Promise((resolve) => {
46
+ let data = '';
47
+ const timeout = setTimeout(() => resolve(null), 2000);
48
+
49
+ process.stdin.setEncoding('utf8');
50
+ process.stdin.on('data', (chunk) => { data += chunk; });
51
+ process.stdin.on('end', () => {
52
+ clearTimeout(timeout);
53
+ try { resolve(data ? JSON.parse(data) : null); }
54
+ catch { resolve(null); }
55
+ });
56
+ process.stdin.on('error', () => {
57
+ clearTimeout(timeout);
58
+ resolve(null);
59
+ });
60
+ });
61
+ }
62
+
63
+ // ─── Transcript Reader ──────────────────────────────────────────────────────
64
+
65
+ function getLastAssistantMessage(transcriptPath, maxLength = 0) {
66
+ try {
67
+ if (!fs.existsSync(transcriptPath)) return null;
68
+
69
+ const content = fs.readFileSync(transcriptPath, 'utf8');
70
+ const lines = content.trim().split('\n');
71
+
72
+ for (let i = lines.length - 1; i >= 0; i--) {
73
+ try {
74
+ const entry = JSON.parse(lines[i]);
75
+ if (entry.type === 'assistant' && entry.message?.content) {
76
+ for (const block of entry.message.content) {
77
+ if (block.type === 'text' && block.text) {
78
+ const text = block.text.trim();
79
+ if (text.length > 0) {
80
+ if (maxLength > 0 && text.length > maxLength) {
81
+ return text.substring(0, maxLength) + '...';
82
+ }
83
+ return text;
84
+ }
85
+ }
86
+ }
87
+ }
88
+ } catch { continue; }
89
+ }
90
+ return null;
91
+ } catch { return null; }
92
+ }
93
+
94
+ // ─── Current Turn Parser ────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Parse the last turn from a Claude Code transcript .jsonl file.
98
+ * Reads backwards from the end to find tool_use entries and the final assistant text.
99
+ * @returns {{ toolUses: Array<{tool: string, file?: string}>, summary: string|null }}
100
+ */
101
+ function parseCurrentTurn(transcriptPath) {
102
+ const result = { toolUses: [], summary: null };
103
+ try {
104
+ if (!fs.existsSync(transcriptPath)) return result;
105
+
106
+ const content = fs.readFileSync(transcriptPath, 'utf8');
107
+ const lines = content.trim().split('\n');
108
+
109
+ // Walk backwards collecting entries from the last assistant turn
110
+ let foundAssistant = false;
111
+ for (let i = lines.length - 1; i >= 0; i--) {
112
+ let entry;
113
+ try { entry = JSON.parse(lines[i]); } catch { continue; }
114
+
115
+ if (entry.type === 'user') break; // Stop at the last user message
116
+
117
+ if (entry.type === 'assistant' && entry.message?.content) {
118
+ foundAssistant = true;
119
+ for (const block of entry.message.content) {
120
+ if (block.type === 'tool_use') {
121
+ const toolUse = { tool: block.name };
122
+ // Extract file path from common input patterns
123
+ const input = block.input || {};
124
+ if (input.file_path) toolUse.file = input.file_path;
125
+ else if (input.path) toolUse.file = input.path;
126
+ else if (input.pattern) toolUse.file = input.pattern;
127
+ else if (input.command) toolUse.file = input.command.substring(0, 80);
128
+ result.toolUses.push(toolUse);
129
+ }
130
+ if (block.type === 'text' && block.text && !result.summary) {
131
+ result.summary = block.text.trim();
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ // If we didn't find structured data, fall back to getLastAssistantMessage
138
+ if (!result.summary && !foundAssistant) {
139
+ result.summary = getLastAssistantMessage(transcriptPath);
140
+ }
141
+ } catch { /* return partial result */ }
142
+ return result;
143
+ }
144
+
145
+ // ─── Transcript Path Resolution ────────────────────────────────────────────
146
+
147
+ /**
148
+ * Derive the transcript .jsonl path from CLAUDE_CONFIG_DIR, session ID, and CWD.
149
+ * Claude Code stores sessions at: <configDir>/projects/<cwdHash>/<sessionId>.jsonl
150
+ * where cwdHash is the absolute CWD with '/' replaced by '-'.
151
+ *
152
+ * @param {string} sessionId
153
+ * @param {string} cwd
154
+ * @returns {string|null} Path to the transcript file, or null if not found
155
+ */
156
+ function findTranscriptPath(sessionId, cwd) {
157
+ const configDir = process.env.CLAUDE_CONFIG_DIR;
158
+ if (!configDir || !sessionId || !cwd) return null;
159
+
160
+ const expandedConfigDir = configDir.startsWith('~')
161
+ ? configDir.replace(/^~/, require('os').homedir())
162
+ : configDir;
163
+ const cwdHash = cwd.replace(/\//g, '-');
164
+ const transcriptPath = path.join(expandedConfigDir, 'projects', cwdHash, `${sessionId}.jsonl`);
165
+
166
+ try {
167
+ if (fs.existsSync(transcriptPath)) return transcriptPath;
168
+ } catch { /* fall through */ }
169
+ return null;
170
+ }
171
+
172
+ // ─── tmux Detection ─────────────────────────────────────────────────────────
173
+
174
+ function detectTmuxSession() {
175
+ try {
176
+ return execFileSync('tmux', ['display-message', '-p', '#S'], {
177
+ encoding: 'utf8',
178
+ stdio: ['ignore', 'pipe', 'ignore']
179
+ }).trim();
180
+ } catch { return null; }
181
+ }
182
+
183
+ // ─── Per-Session Mode Check ─────────────────────────────────────────────────
184
+
185
+ function isPerSessionMode() {
186
+ return process.env.CLAUDE_REMOTE_ACCESS === 'true' &&
187
+ !!process.env.SLACK_BOT_TOKEN;
188
+ }
189
+
190
+ function createChannelManager() {
191
+ return new SlackChannelManager({
192
+ botToken: process.env.SLACK_BOT_TOKEN,
193
+ inviteUserId: process.env.SLACK_INVITE_USER_ID,
194
+ channelPrefix: process.env.SLACK_CHANNEL_PREFIX || 'cn'
195
+ });
196
+ }
197
+
198
+ // ─── Progress Buffer Helpers ────────────────────────────────────────────────
199
+
200
+ /**
201
+ * Extract a human-readable detail string from tool_input.
202
+ * @param {string} toolName
203
+ * @param {object} toolInput
204
+ * @returns {string|null}
205
+ */
206
+ function extractToolDetail(toolName, toolInput) {
207
+ if (!toolInput || typeof toolInput !== 'object') return null;
208
+ if (toolInput.file_path) return toolInput.file_path;
209
+ if (toolInput.command) return toolInput.command.substring(0, 120);
210
+ if (toolInput.pattern) return toolInput.pattern;
211
+ if (toolInput.query) return toolInput.query.substring(0, 120);
212
+ if (toolInput.path) return toolInput.path;
213
+ if (toolInput.url) return toolInput.url.substring(0, 120);
214
+ if (toolInput.prompt) return toolInput.prompt.substring(0, 80);
215
+ return null;
216
+ }
217
+
218
+ function progressBufferPath(sessionId) {
219
+ return path.join(PROGRESS_DIR, `progress-${sessionId}.json`);
220
+ }
221
+
222
+ function readProgressBuffer(bufPath) {
223
+ try {
224
+ if (!fs.existsSync(bufPath)) return { events: [], lastFlushTs: 0 };
225
+ const raw = fs.readFileSync(bufPath, 'utf8');
226
+ if (!raw.trim()) return { events: [], lastFlushTs: Date.now() };
227
+ return JSON.parse(raw);
228
+ } catch {
229
+ return { events: [], lastFlushTs: Date.now() };
230
+ }
231
+ }
232
+
233
+ function writeProgressBuffer(bufPath, buf) {
234
+ const dir = path.dirname(bufPath);
235
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
236
+ const tmpFile = path.join(dir, `.progress-${process.pid}.${Date.now()}.tmp`);
237
+ fs.writeFileSync(tmpFile, JSON.stringify(buf), { mode: 0o600 });
238
+ fs.renameSync(tmpFile, bufPath);
239
+ }
240
+
241
+ function appendToProgressBuffer(bufPath, event) {
242
+ const buf = readProgressBuffer(bufPath);
243
+ buf.events.push(event);
244
+ if (buf.events.length > MAX_BUFFER_EVENTS) {
245
+ buf.events = buf.events.slice(-MAX_BUFFER_EVENTS);
246
+ }
247
+ writeProgressBuffer(bufPath, buf);
248
+ return buf;
249
+ }
250
+
251
+ /**
252
+ * Format buffered tool events into a Slack progress message.
253
+ * @param {Array<{type: string, detail: string|null, ts: number}>} events
254
+ * @returns {string}
255
+ */
256
+ function formatProgressMessage(events) {
257
+ if (!events || events.length === 0) return ':hourglass_flowing_sand: Working...';
258
+
259
+ // Deduplicate consecutive same-type events, keep last 8
260
+ const deduped = [];
261
+ for (const e of events) {
262
+ const prev = deduped[deduped.length - 1];
263
+ if (prev && prev.type === e.type && prev.detail === e.detail) continue;
264
+ deduped.push(e);
265
+ }
266
+ const recent = deduped.slice(-8);
267
+
268
+ const lines = recent.map(e => {
269
+ const detail = e.detail ? ` \`${e.detail}\`` : '';
270
+ return `\u2022 ${e.type}${detail}`;
271
+ });
272
+ return `:hourglass_flowing_sand: Working...\n${lines.join('\n')}`;
273
+ }
274
+
275
+ /**
276
+ * Format a notification for tools that pause Claude to wait for user input.
277
+ * @param {string} toolName
278
+ * @param {object} toolInput
279
+ * @param {string|null} [transcriptContent] - Last assistant message from transcript (used for plan content)
280
+ * @returns {string}
281
+ */
282
+ function formatWaitingMessage(toolName, toolInput, transcriptContent) {
283
+ const PLAN_INSTRUCTIONS = '\n\n:arrow_right: Reply here: *yes* to approve, or type feedback to revise the plan.';
284
+ if (toolName === 'ExitPlanMode') {
285
+ if (transcriptContent) {
286
+ const mrkdwn = markdownToMrkdwn(transcriptContent);
287
+ const MAX_PLAN_LENGTH = 39000;
288
+ const truncated = mrkdwn.length > MAX_PLAN_LENGTH
289
+ ? mrkdwn.substring(0, MAX_PLAN_LENGTH) + '...'
290
+ : mrkdwn;
291
+ return `:clipboard: *Plan ready \u2014 waiting for approval*\n\n${truncated}${PLAN_INSTRUCTIONS}`;
292
+ }
293
+ return `:clipboard: Plan ready \u2014 waiting for approval.${PLAN_INSTRUCTIONS}\nUse \`!status\` to view the full plan.`;
294
+ }
295
+ if (toolName === 'AskUserQuestion') {
296
+ const questions = toolInput?.questions;
297
+ if (questions && questions.length > 0 && questions[0].question) {
298
+ const q = questions[0].question;
299
+ const truncated = q.length > 200 ? q.substring(0, 200) + '...' : q;
300
+ return `:question: Claude is asking: "${truncated}"\n\n:arrow_right: Reply here with your answer.`;
301
+ }
302
+ return ':question: Claude is asking a question \u2014 reply here with your answer, or use `!status` to view.';
303
+ }
304
+ return ':hourglass: Waiting for input \u2014 reply here or use `!status` to view.';
305
+ }
306
+
307
+ // ─── Main ───────────────────────────────────────────────────────────────────
308
+
309
+ async function main() {
310
+ const notificationType = process.argv[2] || 'completed';
311
+ const hookContext = await readStdin();
312
+
313
+ const currentDir = hookContext?.cwd || process.cwd();
314
+ const projectName = path.basename(currentDir);
315
+ const sessionId = hookContext?.session_id;
316
+
317
+ // Handle session-start: reuse existing channel or create new one
318
+ if (notificationType === 'session-start') {
319
+ if (isPerSessionMode() && sessionId) {
320
+ const manager = createChannelManager();
321
+ const tmuxSession = detectTmuxSession();
322
+
323
+ // Reuse existing channel if one is already active for this tmux session
324
+ const reused = manager.reuseChannelForTmuxSession(sessionId, tmuxSession);
325
+ if (reused) {
326
+ await manager.postToSessionChannel(sessionId, ':arrows_counterclockwise: New conversation started');
327
+ console.log(`Reusing Slack channel #${reused.channelName} for session ${sessionId}`);
328
+ } else {
329
+ await manager.getOrCreateChannel(sessionId, projectName, currentDir, tmuxSession);
330
+ console.log(`Per-session Slack channel created for ${projectName} (session: ${sessionId})`);
331
+ }
332
+ }
333
+ return;
334
+ }
335
+
336
+ // Handle waiting-for-input events from PreToolUse hook (ExitPlanMode, AskUserQuestion)
337
+ // PreToolUse fires BEFORE the tool runs, i.e. when Claude presents the plan or question.
338
+ // PostToolUse fires AFTER the user responds — too late for notification.
339
+ if (notificationType === 'waiting-for-input') {
340
+ if (!isPerSessionMode() || !sessionId) return;
341
+
342
+ const toolName = hookContext?.tool_name;
343
+ const toolInput = hookContext?.tool_input;
344
+ if (!toolName || !WAITING_FOR_INPUT_TOOLS.has(toolName)) return;
345
+
346
+ const manager = createChannelManager();
347
+ await manager.clearProgressMessage(sessionId);
348
+
349
+ // For ExitPlanMode, read the plan content from the transcript
350
+ let transcriptContent = null;
351
+ if (toolName === 'ExitPlanMode') {
352
+ const transcriptPath = hookContext?.transcript_path
353
+ || findTranscriptPath(sessionId, currentDir);
354
+ if (transcriptPath) {
355
+ transcriptContent = getLastAssistantMessage(transcriptPath);
356
+ }
357
+ }
358
+
359
+ const text = formatWaitingMessage(toolName, toolInput, transcriptContent);
360
+ await manager.postToSessionChannel(sessionId, text);
361
+ return;
362
+ }
363
+
364
+ // Handle tool-use events from PostToolUse hook (buffered, flush every 3s)
365
+ if (notificationType === 'tool-use') {
366
+ if (!isPerSessionMode() || !sessionId) return;
367
+
368
+ const toolName = hookContext?.tool_name;
369
+ const toolInput = hookContext?.tool_input;
370
+ if (!toolName) return;
371
+
372
+ const detail = extractToolDetail(toolName, toolInput);
373
+ const event = { type: toolName, detail: detail ? detail.substring(0, 120) : null, ts: Date.now() };
374
+
375
+ const bufPath = progressBufferPath(sessionId);
376
+ const buf = appendToProgressBuffer(bufPath, event);
377
+
378
+ // Flush to Slack if enough time has elapsed
379
+ const now = Date.now();
380
+ if (now - (buf.lastFlushTs || 0) >= FLUSH_INTERVAL_MS) {
381
+ const text = formatProgressMessage(buf.events);
382
+ const manager = createChannelManager();
383
+ await manager.updateProgressMessage(sessionId, text);
384
+ buf.events = [];
385
+ buf.lastFlushTs = now;
386
+ writeProgressBuffer(bufPath, buf);
387
+ }
388
+ return;
389
+ }
390
+
391
+ // Handle sleep-until-reset notifications (spawned by runner.js)
392
+ if (notificationType === 'sleep-until-reset') {
393
+ if (!isPerSessionMode()) return;
394
+
395
+ const manager = createChannelManager();
396
+ const resolvedId = sessionId || manager.getSessionByCwd(currentDir)?.sessionId;
397
+ if (!resolvedId) return;
398
+
399
+ const { current_account, sleep_ms, reset_at } = hookContext || {};
400
+ const hours = Math.floor((sleep_ms || 0) / (1000 * 60 * 60));
401
+ const minutes = Math.floor(((sleep_ms || 0) % (1000 * 60 * 60)) / (1000 * 60));
402
+ const duration = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
403
+ const text = `:zzz: All accounts near rate limit. Sleeping for ${duration} (until reset).\nCurrent account: "${current_account}"`;
404
+ await manager.postToSessionChannel(resolvedId, text);
405
+ return;
406
+ }
407
+
408
+ // Handle sleep-wake notifications (spawned by runner.js)
409
+ if (notificationType === 'sleep-wake') {
410
+ if (!isPerSessionMode()) return;
411
+
412
+ const manager = createChannelManager();
413
+ const resolvedId = sessionId || manager.getSessionByCwd(currentDir)?.sessionId;
414
+ if (!resolvedId) return;
415
+
416
+ const { best_account } = hookContext || {};
417
+ const text = best_account
418
+ ? `:sunrise: Woke up! Switching to "${best_account}".`
419
+ : ':sunrise: Woke up! Re-checking accounts...';
420
+ await manager.postToSessionChannel(resolvedId, text);
421
+ return;
422
+ }
423
+
424
+ // Handle account-switch notifications (spawned by runner.js)
425
+ if (notificationType === 'account-switch') {
426
+ if (!isPerSessionMode()) return;
427
+
428
+ // Resolve sessionId: prefer explicit, fall back to CWD lookup
429
+ const manager = createChannelManager();
430
+ const resolvedId = sessionId || manager.getSessionByCwd(currentDir)?.sessionId;
431
+ if (!resolvedId) return;
432
+
433
+ const { from_account, to_account, reason, swap_count, max_swaps } = hookContext || {};
434
+ const text = `:arrows_counterclockwise: Rate limited on "${from_account}", switching to "${to_account}" (swap ${swap_count}/${max_swaps})${reason ? ` \u2014 ${reason}` : ''}`;
435
+ await manager.postToSessionChannel(resolvedId, text);
436
+ return;
437
+ }
438
+
439
+ // Post structured completion to per-session Slack channel
440
+ if (isPerSessionMode() && sessionId) {
441
+ const manager = createChannelManager();
442
+ await manager.clearTypingIndicator(sessionId);
443
+ await manager.clearProgressMessage(sessionId);
444
+
445
+ const transcriptPath = hookContext?.transcript_path;
446
+ const turn = transcriptPath ? parseCurrentTurn(transcriptPath) : { toolUses: [], summary: null };
447
+ const summary = turn.summary
448
+ ? markdownToMrkdwn(turn.summary)
449
+ : null;
450
+
451
+ // Post the assistant's response as a plain message (no Block Kit chrome)
452
+ const messageText = summary || '_No response_';
453
+ const truncatedMessage = messageText.length > 39500
454
+ ? messageText.substring(0, 39500) + '...'
455
+ : messageText;
456
+ const posted = await manager.postToSessionChannel(sessionId, truncatedMessage);
457
+
458
+ // If message was truncated, post the full text as a thread reply
459
+ if (posted && messageText.length > 39500 && transcriptPath) {
460
+ const fullMessage = getLastAssistantMessage(transcriptPath);
461
+ if (fullMessage) {
462
+ const threadMessage = markdownToMrkdwn(fullMessage);
463
+ const mapping = manager.getChannelMapping(sessionId);
464
+ if (mapping) {
465
+ try {
466
+ const historyResult = await manager.client.conversations.history({
467
+ channel: mapping.channelId,
468
+ limit: 1
469
+ });
470
+ const latestTs = historyResult.messages?.[0]?.ts;
471
+ if (latestTs) {
472
+ const MAX_THREAD = 39500;
473
+ const truncatedThread = threadMessage.length > MAX_THREAD
474
+ ? threadMessage.substring(0, MAX_THREAD) + '...'
475
+ : threadMessage;
476
+ await manager.postToThread(sessionId, latestTs, truncatedThread);
477
+ }
478
+ } catch (err) {
479
+ console.warn('Failed to post thread reply:', err.message);
480
+ }
481
+ }
482
+ }
483
+ }
484
+
485
+ if (!posted) {
486
+ console.warn('Failed to post to session channel');
487
+ }
488
+ }
489
+ }
490
+
491
+ if (require.main === module) {
492
+ main().catch(error => {
493
+ console.error('Hook notification error:', error.message);
494
+ process.exit(1);
495
+ });
496
+ }
497
+
498
+ module.exports = {
499
+ getLastAssistantMessage, parseCurrentTurn, isPerSessionMode, readStdin, markdownToMrkdwn,
500
+ extractToolDetail, formatProgressMessage, formatWaitingMessage, findTranscriptPath,
501
+ // Buffer helpers exported for testing
502
+ readProgressBuffer, writeProgressBuffer, appendToProgressBuffer, progressBufferPath,
503
+ FLUSH_INTERVAL_MS, WAITING_FOR_INPUT_TOOLS,
504
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Load .env from ~/.claude-nonstop/.env (preferred) or project root (legacy fallback).
3
+ * Simple parser — no dotenv dependency needed.
4
+ * Existing env vars are NOT overwritten.
5
+ */
6
+
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const { ENV_PATH } = require('./paths.cjs');
10
+
11
+ const legacyEnvPath = path.join(__dirname, '..', '.env');
12
+
13
+ // Prefer new location; fall back to legacy project-root location
14
+ let envPath = ENV_PATH;
15
+ if (!fs.existsSync(envPath) && fs.existsSync(legacyEnvPath)) {
16
+ envPath = legacyEnvPath;
17
+ }
18
+
19
+ if (fs.existsSync(envPath)) {
20
+ const envContent = fs.readFileSync(envPath, 'utf8');
21
+ for (const line of envContent.split('\n')) {
22
+ const trimmed = line.trim();
23
+ if (!trimmed || trimmed.startsWith('#')) continue;
24
+ const eqIdx = trimmed.indexOf('=');
25
+ if (eqIdx === -1) continue;
26
+ const key = trimmed.substring(0, eqIdx).trim();
27
+ const value = trimmed.substring(eqIdx + 1).trim();
28
+ if (!process.env[key]) {
29
+ process.env[key] = value;
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shared path constants for CJS remote/ modules.
3
+ * Must stay in sync with lib/config.js CONFIG_DIR.
4
+ */
5
+
6
+ const path = require('path');
7
+ const os = require('os');
8
+
9
+ const CONFIG_DIR = path.join(os.homedir(), '.claude-nonstop');
10
+ const ENV_PATH = path.join(CONFIG_DIR, '.env');
11
+ const DATA_DIR = path.join(CONFIG_DIR, 'data');
12
+ const CHANNEL_MAP_PATH = path.join(DATA_DIR, 'channel-map.json');
13
+ const PROGRESS_DIR = path.join(DATA_DIR, 'progress');
14
+ const LOG_DIR = path.join(CONFIG_DIR, 'logs');
15
+ const LOG_PATH = path.join(LOG_DIR, 'webhook.log');
16
+
17
+ module.exports = { CONFIG_DIR, ENV_PATH, DATA_DIR, CHANNEL_MAP_PATH, PROGRESS_DIR, LOG_DIR, LOG_PATH };
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Start Slack Webhook
5
+ * Runs the Slack bot in Socket Mode to receive and relay commands.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const { LOG_DIR, LOG_PATH } = require('./paths.cjs');
10
+
11
+ const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
12
+ const LOG_CHECK_INTERVAL = 60_000; // 60 s
13
+
14
+ /**
15
+ * Redirect stdout/stderr to a rotating log file (daemon mode only).
16
+ * Keeps one backup: webhook.log.1.
17
+ */
18
+ function setupLogging() {
19
+ if (process.stdout.isTTY) return;
20
+
21
+ if (!fs.existsSync(LOG_DIR)) {
22
+ fs.mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
23
+ }
24
+
25
+ const prevPath = LOG_PATH + '.1';
26
+
27
+ // Rotate on startup if already over limit
28
+ try {
29
+ if (fs.existsSync(LOG_PATH) && fs.statSync(LOG_PATH).size > MAX_LOG_SIZE) {
30
+ fs.renameSync(LOG_PATH, prevPath);
31
+ }
32
+ } catch {}
33
+
34
+ let logStream = fs.createWriteStream(LOG_PATH, { flags: 'a', mode: 0o600 });
35
+
36
+ const write = (chunk, encoding, callback) => {
37
+ logStream.write(chunk, encoding, callback);
38
+ return true;
39
+ };
40
+
41
+ process.stdout.write = write;
42
+ process.stderr.write = write;
43
+
44
+ // Periodic rotation check
45
+ const interval = setInterval(() => {
46
+ try {
47
+ if (fs.statSync(LOG_PATH).size > MAX_LOG_SIZE) {
48
+ logStream.end();
49
+ try { fs.renameSync(LOG_PATH, prevPath); } catch {}
50
+ logStream = fs.createWriteStream(LOG_PATH, { flags: 'a', mode: 0o600 });
51
+ }
52
+ } catch {}
53
+ }, LOG_CHECK_INTERVAL);
54
+ interval.unref();
55
+ }
56
+
57
+ setupLogging();
58
+
59
+ require('./load-env.cjs');
60
+
61
+ const SlackWebhook = require('./webhook.cjs');
62
+
63
+ async function main() {
64
+ if (!process.env.SLACK_BOT_TOKEN) {
65
+ console.log('SLACK_BOT_TOKEN is required. Run "claude-nonstop setup" to configure.');
66
+ process.exit(1);
67
+ }
68
+
69
+ if (!process.env.SLACK_APP_TOKEN) {
70
+ console.log('SLACK_APP_TOKEN is required for Socket Mode. Run "claude-nonstop setup" to configure.');
71
+ process.exit(1);
72
+ }
73
+
74
+ const webhook = new SlackWebhook({
75
+ botToken: process.env.SLACK_BOT_TOKEN,
76
+ appToken: process.env.SLACK_APP_TOKEN,
77
+ allowedUsers: process.env.SLACK_ALLOWED_USERS?.split(',').map(s => s.trim()).filter(Boolean),
78
+ });
79
+
80
+ process.on('SIGINT', async () => {
81
+ console.log('\nShutting down...');
82
+ await webhook.stop();
83
+ process.exit(0);
84
+ });
85
+
86
+ process.on('SIGTERM', async () => {
87
+ await webhook.stop();
88
+ process.exit(0);
89
+ });
90
+
91
+ await webhook.start();
92
+ }
93
+
94
+ main().catch(error => {
95
+ console.error('Failed to start Slack webhook:', error.message);
96
+ process.exit(1);
97
+ });