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,228 @@
1
+ /**
2
+ * Slack Webhook Handler
3
+ * Handles incoming messages from Slack via Socket Mode.
4
+ * Relays messages to Claude Code tmux sessions.
5
+ */
6
+
7
+ const { App } = require('@slack/bolt');
8
+ const path = require('path');
9
+ const fs = require('fs');
10
+ const { spawnSync } = require('child_process');
11
+ const SlackChannelManager = require('./channel-manager.cjs');
12
+
13
+ class SlackWebhook {
14
+ constructor(config = {}) {
15
+ this.config = config;
16
+ this.app = null;
17
+ this._channelManager = null;
18
+ }
19
+
20
+ _getChannelManager() {
21
+ if (!this._channelManager) {
22
+ this._channelManager = new SlackChannelManager({
23
+ botToken: this.config.botToken,
24
+ inviteUserId: process.env.SLACK_INVITE_USER_ID,
25
+ channelPrefix: process.env.SLACK_CHANNEL_PREFIX || 'cn'
26
+ });
27
+ }
28
+ return this._channelManager;
29
+ }
30
+
31
+ _isUserAllowed(userId) {
32
+ if (!this.config.allowedUsers || this.config.allowedUsers.length === 0) {
33
+ return true;
34
+ }
35
+ return this.config.allowedUsers.includes(userId);
36
+ }
37
+
38
+ async start() {
39
+ if (!this.config.botToken || !this.config.appToken) {
40
+ console.error('Slack Bot Token and App Token are required');
41
+ return;
42
+ }
43
+
44
+ this.app = new App({
45
+ token: this.config.botToken,
46
+ appToken: this.config.appToken,
47
+ socketMode: true
48
+ });
49
+
50
+ // Handle messages in session channels and DMs
51
+ this.app.message(async ({ message, say }) => {
52
+ try {
53
+ console.log('Received message:', message.text);
54
+ if (message.subtype || message.bot_id) return;
55
+
56
+ const text = message.text?.trim() || '';
57
+ if (!text) return;
58
+
59
+ // Per-session channel handling
60
+ const channelManager = this._getChannelManager();
61
+ const sessionInfo = channelManager.getSessionByChannelId(message.channel);
62
+ if (sessionInfo && sessionInfo.active) {
63
+ if (!this._isUserAllowed(message.user)) {
64
+ await say(':no_entry: You are not authorized to send commands.');
65
+ return;
66
+ }
67
+
68
+ if (text === '!archive') {
69
+ await say(':file_folder: Archiving this session channel...');
70
+ await channelManager.archiveChannel(message.channel);
71
+ return;
72
+ }
73
+
74
+ if (text === '!stop') {
75
+ if (sessionInfo.tmuxSession) {
76
+ spawnSync('tmux', ['send-keys', '-t', sessionInfo.tmuxSession, 'C-c']);
77
+ await say(':stop_sign: Sent interrupt to Claude');
78
+ } else {
79
+ await say('No tmux session associated with this channel.');
80
+ }
81
+ return;
82
+ }
83
+
84
+ if (text === '!status') {
85
+ if (sessionInfo.tmuxSession) {
86
+ const result = spawnSync('tmux', ['capture-pane', '-p', '-t', sessionInfo.tmuxSession], {
87
+ encoding: 'utf8',
88
+ timeout: 5000,
89
+ });
90
+ if (result.error || result.status !== 0) {
91
+ await say(':warning: Failed to capture terminal — tmux session may have ended');
92
+ } else {
93
+ let paneContent = (result.stdout || '').trimEnd();
94
+ if (paneContent.length > 3900) {
95
+ paneContent = paneContent.substring(paneContent.length - 3900);
96
+ }
97
+ await say('```\n' + paneContent + '\n```');
98
+ }
99
+ } else {
100
+ await say('No tmux session associated with this channel.');
101
+ }
102
+ return;
103
+ }
104
+
105
+ if (text === '!help') {
106
+ await say(':information_source: *Available commands:*\n\u2022 `!stop` \u2014 interrupt Claude (Ctrl+C)\n\u2022 `!status` \u2014 show current terminal output\n\u2022 `!cmd <text>` \u2014 relay text verbatim (e.g. `!cmd /clear`)\n\u2022 `!archive` \u2014 archive this channel\n\u2022 `!help` \u2014 show this help');
107
+ return;
108
+ }
109
+
110
+ if (text.startsWith('!cmd ')) {
111
+ const cmdText = text.slice(5);
112
+ if (!cmdText) return;
113
+ if (sessionInfo.tmuxSession) {
114
+ const relayOk = this._executeTmuxCommand(cmdText, { tmuxSession: sessionInfo.tmuxSession });
115
+ if (!relayOk) {
116
+ await say(':warning: Failed to relay message \u2014 tmux session may have ended');
117
+ }
118
+ } else {
119
+ await say('No tmux session associated with this channel.');
120
+ }
121
+ return;
122
+ }
123
+
124
+ if (sessionInfo.tmuxSession) {
125
+ await channelManager.setTypingIndicator(message.channel, message.ts);
126
+ const relayOk = this._executeTmuxCommand(text, { tmuxSession: sessionInfo.tmuxSession });
127
+ if (!relayOk) {
128
+ await say(':warning: Failed to relay message \u2014 tmux session may have ended');
129
+ }
130
+ } else {
131
+ await say('No tmux session associated with this channel.');
132
+ }
133
+ return;
134
+ }
135
+
136
+ // Default tmux session fallback (DMs or dedicated channel)
137
+ const defaultTmuxSession = process.env.DEFAULT_TMUX_SESSION;
138
+ const dedicatedChannel = process.env.SLACK_CHANNEL_ID;
139
+ const isAllowedChannel = message.channel_type === 'im' || message.channel === dedicatedChannel;
140
+
141
+ if (defaultTmuxSession && text.length > 0 && isAllowedChannel) {
142
+ if (!this._isUserAllowed(message.user)) {
143
+ await say(':no_entry: You are not authorized to send commands.');
144
+ return;
145
+ }
146
+
147
+ await say(`:rocket: Sending to tmux session \`${defaultTmuxSession}\`...\n\`${text}\``);
148
+ this._executeTmuxCommand(text, { tmuxSession: defaultTmuxSession });
149
+ return;
150
+ }
151
+ } catch (err) {
152
+ console.error('Message handler error:', err.message);
153
+ }
154
+ });
155
+
156
+ // Handle app mentions
157
+ this.app.event('app_mention', async ({ event, say }) => {
158
+ try {
159
+ console.log('Received app_mention:', event.text);
160
+ const text = event.text.replace(/<@[A-Z0-9]+>/gi, '').trim();
161
+ if (!text) return;
162
+
163
+ const defaultTmuxSession = process.env.DEFAULT_TMUX_SESSION;
164
+ if (defaultTmuxSession) {
165
+ if (!this._isUserAllowed(event.user)) {
166
+ await say(':no_entry: You are not authorized to send commands.');
167
+ return;
168
+ }
169
+
170
+ await say(`:rocket: Sending to tmux session \`${defaultTmuxSession}\`...\n\`${text}\``);
171
+ this._executeTmuxCommand(text, { tmuxSession: defaultTmuxSession });
172
+ }
173
+ } catch (err) {
174
+ console.error('App mention handler error:', err.message);
175
+ }
176
+ });
177
+
178
+ await this.app.start();
179
+ console.log(':zap: Slack bot is running in Socket Mode');
180
+ }
181
+
182
+ /**
183
+ * Send a command to a tmux session.
184
+ * @returns {boolean} true if the text was sent successfully
185
+ */
186
+ _executeTmuxCommand(command, session) {
187
+ const tmuxSession = session.tmuxSession || 'claude';
188
+ const MAX_TMUX_MESSAGE_LENGTH = 4096;
189
+
190
+ // Truncate to prevent terminal flooding
191
+ let safeCommand = command;
192
+ if (safeCommand.length > MAX_TMUX_MESSAGE_LENGTH) {
193
+ safeCommand = safeCommand.substring(0, MAX_TMUX_MESSAGE_LENGTH);
194
+ }
195
+
196
+ try {
197
+ const baseArgs = ['send-keys', '-t', tmuxSession];
198
+
199
+ // Step 1: Send command text (literal mode)
200
+ const textResult = spawnSync('tmux', [...baseArgs, '-l', safeCommand]);
201
+ if (textResult.error || textResult.status !== 0) {
202
+ console.error('tmux send-keys text error:', textResult.error?.message || `exit ${textResult.status}`);
203
+ return false;
204
+ }
205
+
206
+ // Step 2: Send Enter key separately (300ms delay for Claude Code to process)
207
+ setTimeout(() => {
208
+ const enterResult = spawnSync('tmux', [...baseArgs, 'Enter']);
209
+ if (enterResult.error) {
210
+ console.error('tmux send-keys Enter error:', enterResult.error.message);
211
+ }
212
+ }, 300);
213
+ return true;
214
+ } catch (error) {
215
+ console.error('tmux command error:', error.message);
216
+ return false;
217
+ }
218
+ }
219
+
220
+ async stop() {
221
+ if (this.app) {
222
+ await this.app.stop();
223
+ console.log('Slack bot stopped');
224
+ }
225
+ }
226
+ }
227
+
228
+ module.exports = SlackWebhook;
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * postinstall script — restarts the webhook launchd service after npm install.
5
+ *
6
+ * Self-contained: no imports from lib/ (runs before the project is fully set up).
7
+ * Silently exits on any error (must never break npm install).
8
+ */
9
+
10
+ import { platform, homedir } from 'os';
11
+ import { existsSync } from 'fs';
12
+ import { execFileSync } from 'child_process';
13
+ import { join } from 'path';
14
+
15
+ try {
16
+ // Only macOS has launchd
17
+ if (platform() !== 'darwin') process.exit(0);
18
+
19
+ const plistPath = join(homedir(), 'Library', 'LaunchAgents', 'claude-nonstop-slack.plist');
20
+
21
+ // No plist = fresh install, nothing to restart
22
+ if (!existsSync(plistPath)) process.exit(0);
23
+
24
+ const uid = process.getuid();
25
+ const domain = `gui/${uid}`;
26
+ const serviceTarget = `${domain}/claude-nonstop-slack`;
27
+
28
+ try {
29
+ execFileSync('launchctl', ['kickstart', '-k', serviceTarget], { stdio: 'pipe' });
30
+ } catch {
31
+ // Kickstart failed — try bootstrap in case service was unloaded
32
+ try {
33
+ execFileSync('launchctl', ['bootstrap', domain, plistPath], { stdio: 'pipe' });
34
+ } catch {
35
+ // Ignore — service may already be loaded
36
+ }
37
+ }
38
+ } catch {
39
+ // Never fail npm install
40
+ }
@@ -0,0 +1,32 @@
1
+ display_information:
2
+ name: Claude NonStop
3
+ description: Remote access bridge for Claude Code sessions
4
+ background_color: "#4a154b"
5
+ features:
6
+ bot_user:
7
+ display_name: Claude NonStop
8
+ always_online: true
9
+ oauth_config:
10
+ scopes:
11
+ bot:
12
+ - chat:write
13
+ - channels:manage
14
+ - channels:history
15
+ - channels:read
16
+ - reactions:read
17
+ - reactions:write
18
+ - app_mentions:read
19
+ - im:history
20
+ - im:read
21
+ - im:write
22
+ settings:
23
+ event_subscriptions:
24
+ bot_events:
25
+ - message.channels
26
+ - message.im
27
+ - app_mention
28
+ interactivity:
29
+ is_enabled: false
30
+ org_deploy_enabled: false
31
+ socket_mode_enabled: true
32
+ token_rotation_enabled: false