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
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
|
+
}
|