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/lib/service.js ADDED
@@ -0,0 +1,196 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { join, dirname } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import { isMacOS } from './platform.js';
7
+ import { CONFIG_DIR } from './config.js';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const PROJECT_ROOT = join(__dirname, '..');
12
+
13
+ const SERVICE_LABEL = 'claude-nonstop-slack';
14
+ const PLIST_PATH = join(homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
15
+ const LOG_DIR = join(CONFIG_DIR, 'logs');
16
+ const LOG_PATH = join(LOG_DIR, 'webhook.log');
17
+
18
+ /**
19
+ * Generate the launchd plist XML for the webhook service.
20
+ */
21
+ function generatePlist() {
22
+ const webhookScript = join(PROJECT_ROOT, 'remote', 'start-webhook.cjs');
23
+ const nodePath = process.execPath;
24
+ const nodeBinDir = dirname(nodePath);
25
+
26
+ // Build PATH including the directory containing tmux, which may be in
27
+ // /opt/homebrew/bin (Apple Silicon) or elsewhere not in launchd's default PATH.
28
+ const pathDirs = new Set([nodeBinDir, '/usr/local/bin', '/usr/bin', '/bin']);
29
+ try {
30
+ const tmuxPath = execFileSync('which', ['tmux'], { encoding: 'utf8', timeout: 3000 }).trim();
31
+ if (tmuxPath) pathDirs.add(dirname(tmuxPath));
32
+ } catch {
33
+ // tmux not found — include /opt/homebrew/bin as a common fallback
34
+ pathDirs.add('/opt/homebrew/bin');
35
+ }
36
+ const envPath = [...pathDirs].join(':');
37
+
38
+ return `<?xml version="1.0" encoding="UTF-8"?>
39
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
40
+ <plist version="1.0">
41
+ <dict>
42
+ <key>Label</key>
43
+ <string>${SERVICE_LABEL}</string>
44
+ <key>ProgramArguments</key>
45
+ <array>
46
+ <string>${nodePath}</string>
47
+ <string>${webhookScript}</string>
48
+ </array>
49
+ <key>RunAtLoad</key>
50
+ <true/>
51
+ <key>KeepAlive</key>
52
+ <true/>
53
+ <key>ThrottleInterval</key>
54
+ <integer>10</integer>
55
+ <key>EnvironmentVariables</key>
56
+ <dict>
57
+ <key>PATH</key>
58
+ <string>${envPath}</string>
59
+ </dict>
60
+ </dict>
61
+ </plist>
62
+ `;
63
+ }
64
+
65
+ /**
66
+ * Install and start the launchd service.
67
+ */
68
+ function installService() {
69
+ if (!isMacOS()) {
70
+ throw new Error('Service management is only supported on macOS (launchd)');
71
+ }
72
+
73
+ // Ensure log directory exists (user-only permissions)
74
+ if (!existsSync(LOG_DIR)) {
75
+ mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
76
+ }
77
+
78
+ // Ensure LaunchAgents directory exists
79
+ const launchAgentsDir = dirname(PLIST_PATH);
80
+ if (!existsSync(launchAgentsDir)) {
81
+ mkdirSync(launchAgentsDir, { recursive: true });
82
+ }
83
+
84
+ const uid = process.getuid();
85
+ const domain = `gui/${uid}`;
86
+
87
+ // Bootout first if already loaded (so bootstrap picks up the new plist)
88
+ try {
89
+ execFileSync('launchctl', ['bootout', `${domain}/${SERVICE_LABEL}`], { stdio: 'pipe' });
90
+ } catch {
91
+ // Ignore — service may not be loaded
92
+ }
93
+
94
+ // Write plist (after bootout, before bootstrap) with restrictive permissions
95
+ const plist = generatePlist();
96
+ writeFileSync(PLIST_PATH, plist, { mode: 0o600 });
97
+
98
+ // Bootstrap the service (load + start)
99
+ try {
100
+ execFileSync('launchctl', ['bootstrap', domain, PLIST_PATH], { stdio: 'pipe' });
101
+ } catch (err) {
102
+ throw new Error(`Failed to bootstrap service: ${err.stderr?.toString().trim() || err.message}`);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Stop and remove the launchd service.
108
+ */
109
+ function uninstallService() {
110
+ if (!isMacOS()) {
111
+ throw new Error('Service management is only supported on macOS (launchd)');
112
+ }
113
+
114
+ const uid = process.getuid();
115
+ const domain = `gui/${uid}`;
116
+
117
+ // Bootout (stop + unload)
118
+ try {
119
+ execFileSync('launchctl', ['bootout', `${domain}/${SERVICE_LABEL}`], { stdio: 'pipe' });
120
+ } catch {
121
+ // Ignore — service may not be loaded
122
+ }
123
+
124
+ // Delete plist
125
+ if (existsSync(PLIST_PATH)) {
126
+ unlinkSync(PLIST_PATH);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Restart the launchd service (also refreshes the plist).
132
+ */
133
+ function restartService() {
134
+ if (!isMacOS()) {
135
+ throw new Error('Service management is only supported on macOS (launchd)');
136
+ }
137
+
138
+ // Full cycle: bootout, write fresh plist, bootstrap
139
+ // This ensures path changes (node upgrade, project move) are picked up
140
+ installService();
141
+ }
142
+
143
+ /**
144
+ * Get service status including PID.
145
+ * Returns { installed, running, pid }.
146
+ */
147
+ function getServiceStatus() {
148
+ if (!isMacOS()) {
149
+ return { installed: false, running: false, pid: null };
150
+ }
151
+
152
+ const installed = existsSync(PLIST_PATH);
153
+ if (!installed) {
154
+ return { installed: false, running: false, pid: null };
155
+ }
156
+
157
+ const uid = process.getuid();
158
+ const domain = `gui/${uid}`;
159
+
160
+ try {
161
+ const output = execFileSync('launchctl', ['print', `${domain}/${SERVICE_LABEL}`], {
162
+ stdio: ['pipe', 'pipe', 'pipe'],
163
+ encoding: 'utf8',
164
+ });
165
+
166
+ // Parse PID from output (line like "pid = 12345" or "pid = (not running)")
167
+ const pidMatch = output.match(/pid\s*=\s*(\d+)/);
168
+ const pid = pidMatch ? parseInt(pidMatch[1], 10) : null;
169
+ const running = pid !== null;
170
+
171
+ return { installed: true, running, pid };
172
+ } catch {
173
+ // Service not loaded
174
+ return { installed: true, running: false, pid: null };
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Check if the service plist is installed.
180
+ */
181
+ function isServiceInstalled() {
182
+ return isMacOS() && existsSync(PLIST_PATH);
183
+ }
184
+
185
+ export {
186
+ SERVICE_LABEL,
187
+ PLIST_PATH,
188
+ LOG_PATH,
189
+ LOG_DIR,
190
+ generatePlist,
191
+ installService,
192
+ uninstallService,
193
+ restartService,
194
+ getServiceStatus,
195
+ isServiceInstalled,
196
+ };
package/lib/session.js ADDED
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Session migration between Claude Code config directories.
3
+ *
4
+ * Claude Code stores sessions at:
5
+ * <CLAUDE_CONFIG_DIR>/projects/<cwdHash>/<sessionId>.jsonl
6
+ *
7
+ * The cwdHash is the absolute CWD path with '/' replaced by '-'.
8
+ * Example: /Users/rc/code/myproject -> -Users-rc-code-myproject
9
+ *
10
+ * When switching accounts mid-session, we copy the session files
11
+ * to the new account's config dir so `claude --resume` can find them.
12
+ */
13
+
14
+ import { existsSync, mkdirSync, cpSync, readdirSync, statSync } from 'fs';
15
+ import { join, basename } from 'path';
16
+ import { homedir } from 'os';
17
+
18
+ // UUID v4 pattern — Claude Code session IDs are always UUIDs
19
+ const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
+
21
+ /**
22
+ * Validate a session ID to prevent path traversal.
23
+ * Session IDs must be valid UUID v4 strings.
24
+ *
25
+ * @param {string} sessionId
26
+ * @throws {Error} if the session ID is invalid
27
+ */
28
+ export function validateSessionId(sessionId) {
29
+ if (typeof sessionId !== 'string' || !SESSION_ID_PATTERN.test(sessionId)) {
30
+ throw new Error('Invalid session ID: must be a valid UUID');
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Compute the project directory hash for a CWD.
36
+ * Claude Code uses the absolute path with '/' replaced by '-'.
37
+ *
38
+ * @param {string} cwd - The working directory
39
+ * @returns {string} The hashed directory name
40
+ */
41
+ export function getCwdHash(cwd) {
42
+ const resolved = cwd.startsWith('~')
43
+ ? cwd.replace(/^~/, homedir())
44
+ : cwd;
45
+ // Replace all path separators with '-'
46
+ return resolved.replace(/\//g, '-');
47
+ }
48
+
49
+ /**
50
+ * Get the projects directory for a config dir and CWD.
51
+ *
52
+ * @param {string} configDir - The CLAUDE_CONFIG_DIR
53
+ * @param {string} cwd - The working directory
54
+ * @returns {string} Full path to the project's session directory
55
+ */
56
+ export function getProjectDir(configDir, cwd) {
57
+ const expanded = configDir.startsWith('~')
58
+ ? configDir.replace(/^~/, homedir())
59
+ : configDir;
60
+ return join(expanded, 'projects', getCwdHash(cwd));
61
+ }
62
+
63
+ /**
64
+ * Find the most recently modified session in a project directory.
65
+ *
66
+ * @param {string} configDir - The CLAUDE_CONFIG_DIR
67
+ * @param {string} cwd - The working directory
68
+ * @returns {{ sessionId: string, path: string } | null}
69
+ */
70
+ export function findLatestSession(configDir, cwd) {
71
+ const projectDir = getProjectDir(configDir, cwd);
72
+
73
+ if (!existsSync(projectDir)) return null;
74
+
75
+ let latest = null;
76
+ let latestMtime = 0;
77
+
78
+ try {
79
+ const entries = readdirSync(projectDir);
80
+ for (const entry of entries) {
81
+ if (!entry.endsWith('.jsonl')) continue;
82
+
83
+ const fullPath = join(projectDir, entry);
84
+ try {
85
+ const stat = statSync(fullPath);
86
+ if (stat.mtimeMs > latestMtime) {
87
+ latestMtime = stat.mtimeMs;
88
+ latest = {
89
+ sessionId: basename(entry, '.jsonl'),
90
+ path: fullPath,
91
+ };
92
+ }
93
+ } catch {
94
+ // Skip files we can't stat
95
+ }
96
+ }
97
+ } catch {
98
+ return null;
99
+ }
100
+
101
+ return latest;
102
+ }
103
+
104
+ /**
105
+ * Search all accounts' project directories for a specific session ID.
106
+ *
107
+ * @param {Array<{name: string, configDir: string}>} accounts
108
+ * @param {string} sessionId - The session ID to find
109
+ * @returns {{ account: object, cwdHash: string, path: string, mtime: number } | null}
110
+ */
111
+ export function findSessionAcrossProfiles(accounts, sessionId) {
112
+ validateSessionId(sessionId);
113
+ const filename = `${sessionId}.jsonl`;
114
+ let best = null;
115
+
116
+ for (const account of accounts) {
117
+ const expanded = account.configDir.startsWith('~')
118
+ ? account.configDir.replace(/^~/, homedir())
119
+ : account.configDir;
120
+ const projectsDir = join(expanded, 'projects');
121
+
122
+ if (!existsSync(projectsDir)) continue;
123
+
124
+ let cwdHashes;
125
+ try {
126
+ cwdHashes = readdirSync(projectsDir);
127
+ } catch {
128
+ continue;
129
+ }
130
+
131
+ for (const cwdHash of cwdHashes) {
132
+ const sessionPath = join(projectsDir, cwdHash, filename);
133
+ try {
134
+ const stat = statSync(sessionPath);
135
+ if (!best || stat.mtimeMs > best.mtime) {
136
+ best = { account, cwdHash, path: sessionPath, mtime: stat.mtimeMs };
137
+ }
138
+ } catch {
139
+ // File doesn't exist in this cwdHash — skip
140
+ }
141
+ }
142
+ }
143
+
144
+ return best;
145
+ }
146
+
147
+ /**
148
+ * Find the most recently modified session across all accounts for the current project.
149
+ *
150
+ * @param {Array<{name: string, configDir: string}>} accounts
151
+ * @param {string} cwd - The working directory to scope the search to
152
+ * @returns {{ account: object, cwdHash: string, sessionId: string, path: string, mtime: number } | null}
153
+ */
154
+ export function findLatestSessionAcrossProfiles(accounts, cwd) {
155
+ const cwdHash = getCwdHash(cwd);
156
+ let best = null;
157
+
158
+ for (const account of accounts) {
159
+ const expanded = account.configDir.startsWith('~')
160
+ ? account.configDir.replace(/^~/, homedir())
161
+ : account.configDir;
162
+ const cwdDir = join(expanded, 'projects', cwdHash);
163
+
164
+ if (!existsSync(cwdDir)) continue;
165
+
166
+ let entries;
167
+ try {
168
+ entries = readdirSync(cwdDir);
169
+ } catch {
170
+ continue;
171
+ }
172
+
173
+ for (const entry of entries) {
174
+ if (!entry.endsWith('.jsonl')) continue;
175
+ const fullPath = join(cwdDir, entry);
176
+ try {
177
+ const stat = statSync(fullPath);
178
+ if (!best || stat.mtimeMs > best.mtime) {
179
+ best = {
180
+ account,
181
+ cwdHash,
182
+ sessionId: basename(entry, '.jsonl'),
183
+ path: fullPath,
184
+ mtime: stat.mtimeMs,
185
+ };
186
+ }
187
+ } catch {
188
+ // Skip files we can't stat
189
+ }
190
+ }
191
+ }
192
+
193
+ return best;
194
+ }
195
+
196
+ /**
197
+ * Migrate a session using a cwdHash directly (instead of computing from CWD).
198
+ * Used by the resume command when the original CWD is unknown.
199
+ *
200
+ * @param {string} fromConfigDir - Source config directory
201
+ * @param {string} toConfigDir - Destination config directory
202
+ * @param {string} cwdHash - The project directory hash
203
+ * @param {string} sessionId - The session ID to migrate
204
+ * @returns {{ success: boolean, error?: string }}
205
+ */
206
+ export function migrateSessionByHash(fromConfigDir, toConfigDir, cwdHash, sessionId) {
207
+ validateSessionId(sessionId);
208
+ try {
209
+ const expandFrom = fromConfigDir.startsWith('~')
210
+ ? fromConfigDir.replace(/^~/, homedir())
211
+ : fromConfigDir;
212
+ const expandTo = toConfigDir.startsWith('~')
213
+ ? toConfigDir.replace(/^~/, homedir())
214
+ : toConfigDir;
215
+
216
+ const fromProjectDir = join(expandFrom, 'projects', cwdHash);
217
+ const toProjectDir = join(expandTo, 'projects', cwdHash);
218
+
219
+ if (!existsSync(toProjectDir)) {
220
+ mkdirSync(toProjectDir, { recursive: true });
221
+ }
222
+
223
+ const sessionFile = `${sessionId}.jsonl`;
224
+ const fromSession = join(fromProjectDir, sessionFile);
225
+ const toSession = join(toProjectDir, sessionFile);
226
+
227
+ if (!existsSync(fromSession)) {
228
+ return { success: false, error: `Session file not found: ${fromSession}` };
229
+ }
230
+
231
+ cpSync(fromSession, toSession, { force: true });
232
+
233
+ // Copy tool-results directory if it exists
234
+ const fromToolResults = join(fromProjectDir, sessionId);
235
+ const toToolResults = join(toProjectDir, sessionId);
236
+
237
+ if (existsSync(fromToolResults)) {
238
+ cpSync(fromToolResults, toToolResults, { recursive: true, force: true });
239
+ }
240
+
241
+ return { success: true };
242
+ } catch (error) {
243
+ return { success: false, error: error.message };
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Migrate a session from one config dir to another.
249
+ *
250
+ * Copies:
251
+ * - The session .jsonl file
252
+ * - The associated tool-results directory (if it exists)
253
+ *
254
+ * @param {string} fromConfigDir - Source config directory
255
+ * @param {string} toConfigDir - Destination config directory
256
+ * @param {string} cwd - The working directory
257
+ * @param {string} sessionId - The session ID to migrate
258
+ * @returns {{ success: boolean, error?: string }}
259
+ */
260
+ export function migrateSession(fromConfigDir, toConfigDir, cwd, sessionId) {
261
+ validateSessionId(sessionId);
262
+ try {
263
+ const fromProjectDir = getProjectDir(fromConfigDir, cwd);
264
+ const toProjectDir = getProjectDir(toConfigDir, cwd);
265
+
266
+ // Ensure destination project directory exists
267
+ if (!existsSync(toProjectDir)) {
268
+ mkdirSync(toProjectDir, { recursive: true });
269
+ }
270
+
271
+ // Copy session file
272
+ const sessionFile = `${sessionId}.jsonl`;
273
+ const fromSession = join(fromProjectDir, sessionFile);
274
+ const toSession = join(toProjectDir, sessionFile);
275
+
276
+ if (!existsSync(fromSession)) {
277
+ return { success: false, error: `Session file not found: ${fromSession}` };
278
+ }
279
+
280
+ cpSync(fromSession, toSession, { force: true });
281
+
282
+ // Copy tool-results directory if it exists
283
+ const fromToolResults = join(fromProjectDir, sessionId);
284
+ const toToolResults = join(toProjectDir, sessionId);
285
+
286
+ if (existsSync(fromToolResults)) {
287
+ cpSync(fromToolResults, toToolResults, { recursive: true, force: true });
288
+ }
289
+
290
+ return { success: true };
291
+ } catch (error) {
292
+ return { success: false, error: error.message };
293
+ }
294
+ }
package/lib/tmux.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * tmux session management for --remote-access mode.
3
+ *
4
+ * Handles detecting if we're inside tmux, generating session names,
5
+ * and re-executing the current process inside a new tmux session.
6
+ */
7
+
8
+ import { execFileSync } from 'child_process';
9
+ import { basename } from 'path';
10
+ import { createHash } from 'crypto';
11
+
12
+ /**
13
+ * Check if currently running inside a tmux session.
14
+ * @returns {boolean}
15
+ */
16
+ export function isInsideTmux() {
17
+ return !!process.env.TMUX;
18
+ }
19
+
20
+ /**
21
+ * Generate a tmux session name from the current working directory.
22
+ * Uses basename + short hash of full path to avoid collisions when
23
+ * multiple projects share the same directory name.
24
+ * Example: /Users/rc/code/myproject -> "myproject-a1b2c3"
25
+ * @returns {string}
26
+ */
27
+ export function generateSessionName() {
28
+ const cwd = process.cwd();
29
+ const hash = createHash('sha256').update(cwd).digest('hex').slice(0, 6);
30
+ const base = `${basename(cwd)}-${hash}`;
31
+ if (!tmuxSessionExists(base)) return base;
32
+ let counter = 2;
33
+ while (tmuxSessionExists(`${base}-${counter}`)) counter++;
34
+ return `${base}-${counter}`;
35
+ }
36
+
37
+ /**
38
+ * Get the name of the current tmux session (must be called from inside tmux).
39
+ * @returns {string|null} Session name, or null if not inside tmux.
40
+ */
41
+ export function getCurrentTmuxSession() {
42
+ try {
43
+ return execFileSync('tmux', ['display-message', '-p', '#S'], {
44
+ encoding: 'utf8',
45
+ stdio: ['ignore', 'pipe', 'ignore'],
46
+ }).trim();
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Check if a tmux session with the given name exists.
54
+ * @param {string} name
55
+ * @returns {boolean}
56
+ */
57
+ export function tmuxSessionExists(name) {
58
+ try {
59
+ execFileSync('tmux', ['has-session', '-t', name], {
60
+ stdio: ['ignore', 'ignore', 'ignore'],
61
+ });
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Re-exec the current process inside a new or existing tmux session.
70
+ *
71
+ * If a tmux session with the given name exists, attaches to it.
72
+ * If not, creates a new session running the same command.
73
+ *
74
+ * Uses execSync with stdio: 'inherit' so the terminal stays connected
75
+ * to the tmux session. When the user detaches (Ctrl-B D), execSync
76
+ * returns and we exit cleanly.
77
+ *
78
+ * @param {string} sessionName - The tmux session name
79
+ * @param {string[]} argv - The full process.argv to re-invoke
80
+ */
81
+ export function reexecInTmux(sessionName, argv) {
82
+ if (tmuxSessionExists(sessionName)) {
83
+ // Session exists — just attach
84
+ execFileSync('tmux', ['attach-session', '-t', sessionName], {
85
+ stdio: 'inherit',
86
+ });
87
+ } else {
88
+ // Create new session running the same command
89
+ execFileSync('tmux', ['new-session', '-s', sessionName, ...argv], {
90
+ stdio: 'inherit',
91
+ });
92
+ }
93
+
94
+ process.exit(0);
95
+ }