claude-remote-cli 1.9.6 → 2.1.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/README.md +4 -3
- package/dist/bin/claude-remote-cli.js +80 -1
- package/dist/server/index.js +139 -40
- package/dist/server/watcher.js +17 -8
- package/package.json +1 -1
- package/public/app.js +100 -4
- package/public/index.html +9 -0
- package/public/style.css +56 -0
package/README.md
CHANGED
|
@@ -142,9 +142,10 @@ The PIN hash is stored in config under `pinHash`. To reset:
|
|
|
142
142
|
## Features
|
|
143
143
|
|
|
144
144
|
- **PIN-protected access** with rate limiting
|
|
145
|
-
- **
|
|
145
|
+
- **Branch-aware sessions** — create worktrees from new or existing branches with a type-to-search branch picker
|
|
146
|
+
- **Worktree isolation** — each session runs in its own git worktree under `.worktrees/`
|
|
146
147
|
- **Resume sessions** — click inactive worktrees to reconnect with `--continue`
|
|
147
|
-
- **Persistent session names** — display names and timestamps survive server restarts
|
|
148
|
+
- **Persistent session names** — display names, branch names, and timestamps survive server restarts
|
|
148
149
|
- **Clipboard image paste** — paste screenshots directly into remote terminal sessions (macOS clipboard + xclip on Linux)
|
|
149
150
|
- **Yolo mode** — skip permission prompts with `--dangerously-skip-permissions` (per-session checkbox or context menu)
|
|
150
151
|
- **Worktree cleanup** — delete inactive worktrees from the context menu (removes worktree, prunes refs, deletes branch)
|
|
@@ -169,7 +170,7 @@ claude-remote-cli/
|
|
|
169
170
|
│ ├── index.ts # Express server, REST API routes
|
|
170
171
|
│ ├── sessions.ts # PTY session manager (node-pty)
|
|
171
172
|
│ ├── ws.ts # WebSocket relay (PTY ↔ browser)
|
|
172
|
-
│ ├── watcher.ts # File watcher for .
|
|
173
|
+
│ ├── watcher.ts # File watcher for .worktrees/ changes
|
|
173
174
|
│ ├── auth.ts # PIN hashing, verification, rate limiting
|
|
174
175
|
│ ├── config.ts # Config loading/saving, worktree metadata
|
|
175
176
|
│ ├── clipboard.ts # System clipboard operations (image paste)
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
-
import { execFile } from 'node:child_process';
|
|
4
|
+
import { execFile, spawn } from 'node:child_process';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import * as service from '../server/service.js';
|
|
8
8
|
import { DEFAULTS } from '../server/config.js';
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
function execErrorMessage(err, fallback) {
|
|
12
|
+
const e = err;
|
|
13
|
+
return (e.stderr || e.message || fallback).trimEnd();
|
|
14
|
+
}
|
|
11
15
|
// Parse CLI flags
|
|
12
16
|
const args = process.argv.slice(2);
|
|
13
17
|
if (args.includes('--help') || args.includes('-h')) {
|
|
@@ -19,12 +23,17 @@ Commands:
|
|
|
19
23
|
install Install as a background service (survives reboot)
|
|
20
24
|
uninstall Stop and remove the background service
|
|
21
25
|
status Show whether the service is running
|
|
26
|
+
worktree Manage git worktrees (wraps git worktree)
|
|
27
|
+
add [path] [-b branch] [--yolo] Create worktree and launch Claude
|
|
28
|
+
remove <path> Forward to git worktree remove
|
|
29
|
+
list Forward to git worktree list
|
|
22
30
|
|
|
23
31
|
Options:
|
|
24
32
|
--bg Shortcut: install and start as background service
|
|
25
33
|
--port <port> Override server port (default: 3456)
|
|
26
34
|
--host <host> Override bind address (default: 0.0.0.0)
|
|
27
35
|
--config <path> Path to config.json (default: ~/.config/claude-remote-cli/config.json)
|
|
36
|
+
--yolo With 'worktree add': pass --dangerously-skip-permissions to Claude
|
|
28
37
|
--version, -v Show version
|
|
29
38
|
--help, -h Show this help`);
|
|
30
39
|
process.exit(0);
|
|
@@ -87,6 +96,76 @@ if (command === 'update') {
|
|
|
87
96
|
}
|
|
88
97
|
process.exit(0);
|
|
89
98
|
}
|
|
99
|
+
if (command === 'worktree') {
|
|
100
|
+
const wtArgs = args.slice(1);
|
|
101
|
+
const subCommand = wtArgs[0];
|
|
102
|
+
if (!subCommand) {
|
|
103
|
+
console.error('Usage: claude-remote-cli worktree <add|remove|list> [options]');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
if (subCommand !== 'add') {
|
|
107
|
+
try {
|
|
108
|
+
const result = await execFileAsync('git', ['worktree', ...wtArgs]);
|
|
109
|
+
if (result.stdout)
|
|
110
|
+
console.log(result.stdout.trimEnd());
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
console.error(execErrorMessage(err, 'git worktree failed'));
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
// Handle 'add' -- strip --yolo, determine path, forward to git, then launch claude
|
|
119
|
+
const hasYolo = wtArgs.includes('--yolo');
|
|
120
|
+
const gitWtArgs = wtArgs.filter(function (a) { return a !== '--yolo'; });
|
|
121
|
+
const addSubArgs = gitWtArgs.slice(1);
|
|
122
|
+
let targetDir;
|
|
123
|
+
const bIdx = gitWtArgs.indexOf('-b');
|
|
124
|
+
const branchForDefault = bIdx !== -1 && bIdx + 1 < gitWtArgs.length ? gitWtArgs[bIdx + 1] : undefined;
|
|
125
|
+
if (addSubArgs.length === 0 || addSubArgs[0].startsWith('-')) {
|
|
126
|
+
let repoRoot;
|
|
127
|
+
try {
|
|
128
|
+
const result = await execFileAsync('git', ['rev-parse', '--show-toplevel']);
|
|
129
|
+
repoRoot = result.stdout.trim();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
console.error('Not inside a git repository.');
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
const dirName = branchForDefault
|
|
136
|
+
? branchForDefault.replace(/\//g, '-')
|
|
137
|
+
: 'worktree-' + Date.now().toString(36);
|
|
138
|
+
targetDir = path.join(repoRoot, '.worktrees', dirName);
|
|
139
|
+
gitWtArgs.splice(1, 0, targetDir);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
targetDir = path.resolve(addSubArgs[0]);
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const result = await execFileAsync('git', ['worktree', ...gitWtArgs]);
|
|
146
|
+
if (result.stdout)
|
|
147
|
+
console.log(result.stdout.trimEnd());
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
console.error(execErrorMessage(err, 'git worktree add failed'));
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
console.log(`Worktree created at ${targetDir}`);
|
|
154
|
+
const claudeArgs = [];
|
|
155
|
+
if (hasYolo)
|
|
156
|
+
claudeArgs.push('--dangerously-skip-permissions');
|
|
157
|
+
console.log(`Launching claude${hasYolo ? ' (yolo mode)' : ''} in ${targetDir}...`);
|
|
158
|
+
const child = spawn('claude', claudeArgs, {
|
|
159
|
+
cwd: targetDir,
|
|
160
|
+
stdio: 'inherit',
|
|
161
|
+
env: { ...process.env, CLAUDECODE: undefined },
|
|
162
|
+
});
|
|
163
|
+
child.on('exit', (code) => {
|
|
164
|
+
process.exit(code ?? 0);
|
|
165
|
+
});
|
|
166
|
+
// Block until child exits via the handler above
|
|
167
|
+
await new Promise(() => { });
|
|
168
|
+
}
|
|
90
169
|
if (command === 'install' || command === 'uninstall' || command === 'status' || args.includes('--bg')) {
|
|
91
170
|
if (command === 'uninstall') {
|
|
92
171
|
runServiceCommand(() => { service.uninstall(); });
|
package/dist/server/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { loadConfig, saveConfig, DEFAULTS, readMeta, writeMeta, ensureMetaDir }
|
|
|
12
12
|
import * as auth from './auth.js';
|
|
13
13
|
import * as sessions from './sessions.js';
|
|
14
14
|
import { setupWebSocket } from './ws.js';
|
|
15
|
-
import { WorktreeWatcher } from './watcher.js';
|
|
15
|
+
import { WorktreeWatcher, WORKTREE_DIRS, isValidWorktreePath } from './watcher.js';
|
|
16
16
|
import { isInstalled as serviceIsInstalled } from './service.js';
|
|
17
17
|
import { extensionForMime, setClipboardImage } from './clipboard.js';
|
|
18
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -59,6 +59,10 @@ async function getLatestVersion() {
|
|
|
59
59
|
return null;
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
function execErrorMessage(err, fallback) {
|
|
63
|
+
const e = err;
|
|
64
|
+
return (e.stderr || e.message || fallback).trim();
|
|
65
|
+
}
|
|
62
66
|
function parseTTL(ttl) {
|
|
63
67
|
if (typeof ttl !== 'string')
|
|
64
68
|
return 24 * 60 * 60 * 1000;
|
|
@@ -109,6 +113,24 @@ function scanAllRepos(rootDirs) {
|
|
|
109
113
|
}
|
|
110
114
|
return repos;
|
|
111
115
|
}
|
|
116
|
+
function ensureGitignore(repoPath, entry) {
|
|
117
|
+
const gitignorePath = path.join(repoPath, '.gitignore');
|
|
118
|
+
try {
|
|
119
|
+
if (fs.existsSync(gitignorePath)) {
|
|
120
|
+
const content = fs.readFileSync(gitignorePath, 'utf-8');
|
|
121
|
+
if (content.split('\n').some((line) => line.trim() === entry))
|
|
122
|
+
return;
|
|
123
|
+
const prefix = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
|
|
124
|
+
fs.appendFileSync(gitignorePath, prefix + entry + '\n');
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
fs.writeFileSync(gitignorePath, entry + '\n');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (_) {
|
|
131
|
+
// Non-fatal: gitignore update failure shouldn't block session creation
|
|
132
|
+
}
|
|
133
|
+
}
|
|
112
134
|
async function main() {
|
|
113
135
|
let config;
|
|
114
136
|
try {
|
|
@@ -193,6 +215,27 @@ async function main() {
|
|
|
193
215
|
}
|
|
194
216
|
res.json(repos);
|
|
195
217
|
});
|
|
218
|
+
// GET /branches?repo=<path> — list local and remote branches for a repo
|
|
219
|
+
app.get('/branches', requireAuth, async (req, res) => {
|
|
220
|
+
const repoPath = typeof req.query.repo === 'string' ? req.query.repo : undefined;
|
|
221
|
+
if (!repoPath) {
|
|
222
|
+
res.status(400).json({ error: 'repo query parameter is required' });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
const { stdout } = await execFileAsync('git', ['branch', '-a', '--format=%(refname:short)'], { cwd: repoPath });
|
|
227
|
+
const branches = stdout
|
|
228
|
+
.split('\n')
|
|
229
|
+
.map((b) => b.trim())
|
|
230
|
+
.filter((b) => b && !b.includes('HEAD'))
|
|
231
|
+
.map((b) => b.replace(/^origin\//, ''));
|
|
232
|
+
const unique = [...new Set(branches)];
|
|
233
|
+
res.json(unique.sort());
|
|
234
|
+
}
|
|
235
|
+
catch (_) {
|
|
236
|
+
res.json([]);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
196
239
|
// GET /worktrees?repo=<path> — list worktrees; omit repo to scan all repos in all rootDirs
|
|
197
240
|
app.get('/worktrees', requireAuth, (req, res) => {
|
|
198
241
|
const repoParam = typeof req.query.repo === 'string' ? req.query.repo : undefined;
|
|
@@ -207,28 +250,30 @@ async function main() {
|
|
|
207
250
|
reposToScan = scanAllRepos(roots);
|
|
208
251
|
}
|
|
209
252
|
for (const repo of reposToScan) {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
for (const entry of entries) {
|
|
219
|
-
if (!entry.isDirectory())
|
|
253
|
+
for (const dir of WORKTREE_DIRS) {
|
|
254
|
+
const worktreeDir = path.join(repo.path, dir);
|
|
255
|
+
let entries;
|
|
256
|
+
try {
|
|
257
|
+
entries = fs.readdirSync(worktreeDir, { withFileTypes: true });
|
|
258
|
+
}
|
|
259
|
+
catch (_) {
|
|
220
260
|
continue;
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
261
|
+
}
|
|
262
|
+
for (const entry of entries) {
|
|
263
|
+
if (!entry.isDirectory())
|
|
264
|
+
continue;
|
|
265
|
+
const wtPath = path.join(worktreeDir, entry.name);
|
|
266
|
+
const meta = readMeta(CONFIG_PATH, wtPath);
|
|
267
|
+
worktrees.push({
|
|
268
|
+
name: entry.name,
|
|
269
|
+
path: wtPath,
|
|
270
|
+
repoName: repo.name,
|
|
271
|
+
repoPath: repo.path,
|
|
272
|
+
root: repo.root,
|
|
273
|
+
displayName: meta ? meta.displayName : '',
|
|
274
|
+
lastActivity: meta ? meta.lastActivity : '',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
232
277
|
}
|
|
233
278
|
}
|
|
234
279
|
res.json(worktrees);
|
|
@@ -276,9 +321,8 @@ async function main() {
|
|
|
276
321
|
res.status(400).json({ error: 'worktreePath and repoPath are required' });
|
|
277
322
|
return;
|
|
278
323
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
res.status(400).json({ error: 'Path is not inside a .claude/worktrees/ directory' });
|
|
324
|
+
if (!isValidWorktreePath(worktreePath)) {
|
|
325
|
+
res.status(400).json({ error: 'Path is not inside a worktree directory' });
|
|
282
326
|
return;
|
|
283
327
|
}
|
|
284
328
|
// Check no active session is using this worktree
|
|
@@ -288,15 +332,15 @@ async function main() {
|
|
|
288
332
|
res.status(409).json({ error: 'Close the active session first' });
|
|
289
333
|
return;
|
|
290
334
|
}
|
|
291
|
-
// Derive branch name from worktree directory name
|
|
292
|
-
const
|
|
335
|
+
// Derive branch name from metadata or worktree directory name
|
|
336
|
+
const meta = readMeta(CONFIG_PATH, worktreePath);
|
|
337
|
+
const branchName = (meta && meta.branchName) || worktreePath.split('/').pop() || '';
|
|
293
338
|
try {
|
|
294
|
-
//
|
|
339
|
+
// Will fail if uncommitted changes -- no --force
|
|
295
340
|
await execFileAsync('git', ['worktree', 'remove', worktreePath], { cwd: repoPath });
|
|
296
341
|
}
|
|
297
342
|
catch (err) {
|
|
298
|
-
|
|
299
|
-
res.status(500).json({ error: message });
|
|
343
|
+
res.status(500).json({ error: execErrorMessage(err, 'Failed to remove worktree') });
|
|
300
344
|
return;
|
|
301
345
|
}
|
|
302
346
|
try {
|
|
@@ -318,8 +362,8 @@ async function main() {
|
|
|
318
362
|
res.json({ ok: true });
|
|
319
363
|
});
|
|
320
364
|
// POST /sessions
|
|
321
|
-
app.post('/sessions', requireAuth, (req, res) => {
|
|
322
|
-
const { repoPath, repoName, worktreePath, claudeArgs } = req.body;
|
|
365
|
+
app.post('/sessions', requireAuth, async (req, res) => {
|
|
366
|
+
const { repoPath, repoName, worktreePath, branchName, claudeArgs } = req.body;
|
|
323
367
|
if (!repoPath) {
|
|
324
368
|
res.status(400).json({ error: 'repoPath is required' });
|
|
325
369
|
return;
|
|
@@ -333,32 +377,87 @@ async function main() {
|
|
|
333
377
|
let cwd;
|
|
334
378
|
let worktreeName;
|
|
335
379
|
let sessionRepoPath;
|
|
380
|
+
let resolvedBranch = '';
|
|
336
381
|
if (worktreePath) {
|
|
337
|
-
// Resume existing worktree
|
|
382
|
+
// Resume existing worktree
|
|
338
383
|
args = ['--continue', ...baseArgs];
|
|
339
384
|
cwd = worktreePath;
|
|
340
385
|
sessionRepoPath = worktreePath;
|
|
341
386
|
worktreeName = worktreePath.split('/').pop() || '';
|
|
342
387
|
}
|
|
343
388
|
else {
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
389
|
+
// Create new worktree via git
|
|
390
|
+
let dirName;
|
|
391
|
+
if (branchName) {
|
|
392
|
+
dirName = branchName.replace(/\//g, '-');
|
|
393
|
+
resolvedBranch = branchName;
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
dirName = 'mobile-' + name + '-' + Date.now().toString(36);
|
|
397
|
+
resolvedBranch = dirName;
|
|
398
|
+
}
|
|
399
|
+
const worktreeDir = path.join(repoPath, '.worktrees');
|
|
400
|
+
let targetDir = path.join(worktreeDir, dirName);
|
|
401
|
+
if (fs.existsSync(targetDir)) {
|
|
402
|
+
targetDir = targetDir + '-' + Date.now().toString(36);
|
|
403
|
+
dirName = path.basename(targetDir);
|
|
404
|
+
}
|
|
405
|
+
ensureGitignore(repoPath, '.worktrees/');
|
|
406
|
+
try {
|
|
407
|
+
// Check if branch exists locally or on a remote
|
|
408
|
+
let branchExists = false;
|
|
409
|
+
if (branchName) {
|
|
410
|
+
const localCheck = await execFileAsync('git', ['rev-parse', '--verify', branchName], { cwd: repoPath }).then(() => true, () => false);
|
|
411
|
+
if (localCheck) {
|
|
412
|
+
branchExists = true;
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
const remoteCheck = await execFileAsync('git', ['rev-parse', '--verify', 'origin/' + branchName], { cwd: repoPath }).then(() => true, () => false);
|
|
416
|
+
if (remoteCheck) {
|
|
417
|
+
branchExists = true;
|
|
418
|
+
resolvedBranch = 'origin/' + branchName;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (branchName && branchExists) {
|
|
423
|
+
await execFileAsync('git', ['worktree', 'add', targetDir, resolvedBranch], { cwd: repoPath });
|
|
424
|
+
}
|
|
425
|
+
else if (branchName) {
|
|
426
|
+
await execFileAsync('git', ['worktree', 'add', '-b', branchName, targetDir, 'HEAD'], { cwd: repoPath });
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
await execFileAsync('git', ['worktree', 'add', '-b', dirName, targetDir, 'HEAD'], { cwd: repoPath });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
res.status(500).json({ error: execErrorMessage(err, 'Failed to create worktree') });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
worktreeName = dirName;
|
|
437
|
+
sessionRepoPath = targetDir;
|
|
438
|
+
cwd = targetDir;
|
|
439
|
+
args = [...baseArgs];
|
|
350
440
|
}
|
|
441
|
+
const displayName = branchName || worktreeName;
|
|
351
442
|
const session = sessions.create({
|
|
352
443
|
repoName: name,
|
|
353
444
|
repoPath: sessionRepoPath,
|
|
354
445
|
cwd,
|
|
355
446
|
root,
|
|
356
447
|
worktreeName,
|
|
357
|
-
displayName
|
|
448
|
+
displayName,
|
|
358
449
|
command: config.claudeCommand,
|
|
359
450
|
args,
|
|
360
451
|
configPath: CONFIG_PATH,
|
|
361
452
|
});
|
|
453
|
+
if (!worktreePath) {
|
|
454
|
+
writeMeta(CONFIG_PATH, {
|
|
455
|
+
worktreePath: sessionRepoPath,
|
|
456
|
+
displayName,
|
|
457
|
+
lastActivity: new Date().toISOString(),
|
|
458
|
+
branchName: branchName || worktreeName,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
362
461
|
res.status(201).json(session);
|
|
363
462
|
});
|
|
364
463
|
// DELETE /sessions/:id
|
package/dist/server/watcher.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { EventEmitter } from 'node:events';
|
|
4
|
+
export const WORKTREE_DIRS = ['.worktrees', '.claude/worktrees'];
|
|
5
|
+
export function isValidWorktreePath(worktreePath) {
|
|
6
|
+
const resolved = path.resolve(worktreePath);
|
|
7
|
+
return WORKTREE_DIRS.some(function (dir) {
|
|
8
|
+
return resolved.includes(path.sep + dir + path.sep);
|
|
9
|
+
});
|
|
10
|
+
}
|
|
4
11
|
export class WorktreeWatcher extends EventEmitter {
|
|
5
12
|
_watchers;
|
|
6
13
|
_debounceTimer;
|
|
@@ -30,16 +37,18 @@ export class WorktreeWatcher extends EventEmitter {
|
|
|
30
37
|
}
|
|
31
38
|
}
|
|
32
39
|
_watchRepo(repoPath) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (fs.existsSync(claudeDir)) {
|
|
40
|
-
this._addWatch(claudeDir);
|
|
40
|
+
let anyWatched = false;
|
|
41
|
+
for (const dir of WORKTREE_DIRS) {
|
|
42
|
+
const worktreeDir = path.join(repoPath, dir);
|
|
43
|
+
if (fs.existsSync(worktreeDir)) {
|
|
44
|
+
this._addWatch(worktreeDir);
|
|
45
|
+
anyWatched = true;
|
|
41
46
|
}
|
|
42
47
|
}
|
|
48
|
+
if (!anyWatched) {
|
|
49
|
+
// Watch repo root so we detect when either dir is first created
|
|
50
|
+
this._addWatch(repoPath);
|
|
51
|
+
}
|
|
43
52
|
}
|
|
44
53
|
_addWatch(dirPath) {
|
|
45
54
|
try {
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
var dialogRootSelect = document.getElementById('dialog-root-select');
|
|
38
38
|
var dialogRepoSelect = document.getElementById('dialog-repo-select');
|
|
39
39
|
var dialogYolo = document.getElementById('dialog-yolo');
|
|
40
|
+
var dialogBranchInput = document.getElementById('dialog-branch-input');
|
|
41
|
+
var dialogBranchList = document.getElementById('dialog-branch-list');
|
|
40
42
|
var contextMenu = document.getElementById('context-menu');
|
|
41
43
|
var ctxResumeYolo = document.getElementById('ctx-resume-yolo');
|
|
42
44
|
var ctxDeleteWorktree = document.getElementById('ctx-delete-worktree');
|
|
@@ -89,8 +91,82 @@
|
|
|
89
91
|
var cachedSessions = [];
|
|
90
92
|
var cachedWorktrees = [];
|
|
91
93
|
var allRepos = [];
|
|
94
|
+
var allBranches = [];
|
|
92
95
|
var attentionSessions = {};
|
|
93
96
|
|
|
97
|
+
function loadBranches(repoPath) {
|
|
98
|
+
allBranches = [];
|
|
99
|
+
dialogBranchList.innerHTML = '';
|
|
100
|
+
dialogBranchList.hidden = true;
|
|
101
|
+
if (!repoPath) return;
|
|
102
|
+
|
|
103
|
+
fetch('/branches?repo=' + encodeURIComponent(repoPath))
|
|
104
|
+
.then(function (res) {
|
|
105
|
+
if (!res.ok) return [];
|
|
106
|
+
return res.json();
|
|
107
|
+
})
|
|
108
|
+
.then(function (data) {
|
|
109
|
+
allBranches = data || [];
|
|
110
|
+
})
|
|
111
|
+
.catch(function () {
|
|
112
|
+
allBranches = [];
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function filterBranches(query) {
|
|
117
|
+
dialogBranchList.innerHTML = '';
|
|
118
|
+
if (!query) {
|
|
119
|
+
dialogBranchList.hidden = true;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
var lower = query.toLowerCase();
|
|
124
|
+
var matches = allBranches.filter(function (b) {
|
|
125
|
+
return b.toLowerCase().indexOf(lower) !== -1;
|
|
126
|
+
}).slice(0, 10);
|
|
127
|
+
|
|
128
|
+
var exactMatch = allBranches.some(function (b) { return b === query; });
|
|
129
|
+
|
|
130
|
+
if (!exactMatch) {
|
|
131
|
+
var createLi = document.createElement('li');
|
|
132
|
+
createLi.className = 'branch-create-new';
|
|
133
|
+
createLi.textContent = 'Create new: ' + query;
|
|
134
|
+
createLi.addEventListener('click', function () {
|
|
135
|
+
dialogBranchInput.value = query;
|
|
136
|
+
dialogBranchList.hidden = true;
|
|
137
|
+
});
|
|
138
|
+
dialogBranchList.appendChild(createLi);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
matches.forEach(function (branch) {
|
|
142
|
+
var li = document.createElement('li');
|
|
143
|
+
li.textContent = branch;
|
|
144
|
+
li.addEventListener('click', function () {
|
|
145
|
+
dialogBranchInput.value = branch;
|
|
146
|
+
dialogBranchList.hidden = true;
|
|
147
|
+
});
|
|
148
|
+
dialogBranchList.appendChild(li);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
dialogBranchList.hidden = dialogBranchList.children.length === 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
dialogBranchInput.addEventListener('input', function () {
|
|
155
|
+
filterBranches(dialogBranchInput.value.trim());
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
dialogBranchInput.addEventListener('focus', function () {
|
|
159
|
+
if (dialogBranchInput.value.trim()) {
|
|
160
|
+
filterBranches(dialogBranchInput.value.trim());
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
document.addEventListener('click', function (e) {
|
|
165
|
+
if (!dialogBranchInput.contains(e.target) && !dialogBranchList.contains(e.target)) {
|
|
166
|
+
dialogBranchList.hidden = true;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
94
170
|
// ── PIN Auth ────────────────────────────────────────────────────────────────
|
|
95
171
|
|
|
96
172
|
function submitPin() {
|
|
@@ -917,6 +993,8 @@
|
|
|
917
993
|
dialogRootSelect.addEventListener('change', function () {
|
|
918
994
|
var root = dialogRootSelect.value;
|
|
919
995
|
dialogRepoSelect.innerHTML = '<option value="">Select a repo...</option>';
|
|
996
|
+
dialogBranchInput.value = '';
|
|
997
|
+
allBranches = [];
|
|
920
998
|
|
|
921
999
|
if (!root) {
|
|
922
1000
|
dialogRepoSelect.disabled = true;
|
|
@@ -934,13 +1012,20 @@
|
|
|
934
1012
|
dialogRepoSelect.disabled = false;
|
|
935
1013
|
});
|
|
936
1014
|
|
|
937
|
-
|
|
1015
|
+
dialogRepoSelect.addEventListener('change', function () {
|
|
1016
|
+
var repoPath = dialogRepoSelect.value;
|
|
1017
|
+
dialogBranchInput.value = '';
|
|
1018
|
+
loadBranches(repoPath);
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
function startSession(repoPath, worktreePath, claudeArgs, branchName) {
|
|
938
1022
|
var body = {
|
|
939
1023
|
repoPath: repoPath,
|
|
940
1024
|
repoName: repoPath.split('/').filter(Boolean).pop(),
|
|
941
1025
|
};
|
|
942
1026
|
if (worktreePath) body.worktreePath = worktreePath;
|
|
943
1027
|
if (claudeArgs) body.claudeArgs = claudeArgs;
|
|
1028
|
+
if (branchName) body.branchName = branchName;
|
|
944
1029
|
|
|
945
1030
|
fetch('/sessions', {
|
|
946
1031
|
method: 'POST',
|
|
@@ -961,6 +1046,9 @@
|
|
|
961
1046
|
newSessionBtn.addEventListener('click', function () {
|
|
962
1047
|
customPath.value = '';
|
|
963
1048
|
dialogYolo.checked = false;
|
|
1049
|
+
dialogBranchInput.value = '';
|
|
1050
|
+
dialogBranchList.hidden = true;
|
|
1051
|
+
allBranches = [];
|
|
964
1052
|
populateDialogRootSelect();
|
|
965
1053
|
|
|
966
1054
|
var sidebarRoot = sidebarRootFilter.value;
|
|
@@ -985,10 +1073,18 @@
|
|
|
985
1073
|
});
|
|
986
1074
|
|
|
987
1075
|
dialogStart.addEventListener('click', function () {
|
|
988
|
-
var
|
|
989
|
-
if (!
|
|
1076
|
+
var repoPathValue = customPath.value.trim() || dialogRepoSelect.value;
|
|
1077
|
+
if (!repoPathValue) return;
|
|
990
1078
|
var args = dialogYolo.checked ? ['--dangerously-skip-permissions'] : undefined;
|
|
991
|
-
|
|
1079
|
+
var branch = dialogBranchInput.value.trim() || undefined;
|
|
1080
|
+
startSession(repoPathValue, undefined, args, branch);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
customPath.addEventListener('blur', function () {
|
|
1084
|
+
var pathValue = customPath.value.trim();
|
|
1085
|
+
if (pathValue) {
|
|
1086
|
+
loadBranches(pathValue);
|
|
1087
|
+
}
|
|
992
1088
|
});
|
|
993
1089
|
|
|
994
1090
|
dialogCancel.addEventListener('click', function () {
|
package/public/index.html
CHANGED
|
@@ -131,6 +131,15 @@
|
|
|
131
131
|
<select id="dialog-repo-select" disabled><option value="">Select a repo...</option></select>
|
|
132
132
|
</div>
|
|
133
133
|
|
|
134
|
+
<div class="dialog-field">
|
|
135
|
+
<label for="dialog-branch-input">Branch</label>
|
|
136
|
+
<div class="branch-input-wrapper">
|
|
137
|
+
<input type="text" id="dialog-branch-input" placeholder="Search or create branch..." autocomplete="off" />
|
|
138
|
+
<ul id="dialog-branch-list" class="branch-dropdown" hidden></ul>
|
|
139
|
+
</div>
|
|
140
|
+
<span class="dialog-option-hint">Leave empty for auto-generated name</span>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
134
143
|
<hr class="dialog-separator" />
|
|
135
144
|
<div class="dialog-custom-path">
|
|
136
145
|
<label for="custom-path-input">Or enter a local path:</label>
|
package/public/style.css
CHANGED
|
@@ -618,6 +618,62 @@ dialog#new-session-dialog h2 {
|
|
|
618
618
|
cursor: not-allowed;
|
|
619
619
|
}
|
|
620
620
|
|
|
621
|
+
/* ===== Branch Input ===== */
|
|
622
|
+
.branch-input-wrapper {
|
|
623
|
+
position: relative;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
.branch-input-wrapper input {
|
|
627
|
+
width: 100%;
|
|
628
|
+
padding: 10px 12px;
|
|
629
|
+
background: var(--bg);
|
|
630
|
+
border: 1px solid var(--border);
|
|
631
|
+
border-radius: 6px;
|
|
632
|
+
color: var(--text);
|
|
633
|
+
font-size: 0.875rem;
|
|
634
|
+
outline: none;
|
|
635
|
+
-webkit-appearance: none;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
.branch-input-wrapper input:focus {
|
|
639
|
+
border-color: var(--accent);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
.branch-dropdown {
|
|
643
|
+
position: absolute;
|
|
644
|
+
top: 100%;
|
|
645
|
+
left: 0;
|
|
646
|
+
right: 0;
|
|
647
|
+
max-height: 200px;
|
|
648
|
+
overflow-y: auto;
|
|
649
|
+
background: var(--bg);
|
|
650
|
+
border: 1px solid var(--border);
|
|
651
|
+
border-top: none;
|
|
652
|
+
border-radius: 0 0 6px 6px;
|
|
653
|
+
list-style: none;
|
|
654
|
+
z-index: 10;
|
|
655
|
+
margin: 0;
|
|
656
|
+
padding: 0;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
.branch-dropdown li {
|
|
660
|
+
padding: 8px 12px;
|
|
661
|
+
font-size: 0.85rem;
|
|
662
|
+
color: var(--text);
|
|
663
|
+
cursor: pointer;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.branch-dropdown li:hover,
|
|
667
|
+
.branch-dropdown li.highlighted {
|
|
668
|
+
background: var(--surface);
|
|
669
|
+
color: var(--accent);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.branch-dropdown li.branch-create-new {
|
|
673
|
+
color: var(--accent);
|
|
674
|
+
font-style: italic;
|
|
675
|
+
}
|
|
676
|
+
|
|
621
677
|
.repo-group-label {
|
|
622
678
|
font-size: 0.75rem;
|
|
623
679
|
font-weight: 600;
|