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.
- package/.env.example +33 -0
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/assets/icon.jpeg +0 -0
- package/assets/screenshot.png +0 -0
- package/bin/claude-nonstop.js +1679 -0
- package/lib/config.js +163 -0
- package/lib/keychain.js +397 -0
- package/lib/platform.js +9 -0
- package/lib/reauth.js +147 -0
- package/lib/runner.js +566 -0
- package/lib/scorer.js +100 -0
- package/lib/service.js +196 -0
- package/lib/session.js +294 -0
- package/lib/tmux.js +95 -0
- package/lib/usage.js +146 -0
- package/package.json +56 -0
- package/remote/channel-manager.cjs +548 -0
- package/remote/hook-notify.cjs +504 -0
- package/remote/load-env.cjs +32 -0
- package/remote/paths.cjs +17 -0
- package/remote/start-webhook.cjs +97 -0
- package/remote/webhook.cjs +228 -0
- package/scripts/postinstall.js +40 -0
- package/slack-manifest.yaml +32 -0
|
@@ -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
|