claude-remote-cli 3.0.2 → 3.0.4
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/dist/bin/claude-remote-cli.js +0 -3
- package/dist/frontend/assets/index-Bw4iKQrv.css +32 -0
- package/dist/frontend/assets/index-C9kPfx3H.js +47 -0
- package/dist/frontend/index.html +2 -2
- package/dist/server/config.js +22 -0
- package/dist/server/git.js +193 -1
- package/dist/server/index.js +51 -292
- package/dist/server/push.js +3 -54
- package/dist/server/sessions.js +265 -180
- package/dist/server/types.js +7 -13
- package/dist/server/workspaces.js +448 -0
- package/dist/server/ws.js +31 -92
- package/dist/test/pr-state.test.js +164 -0
- package/dist/test/pull-requests.test.js +3 -3
- package/dist/test/sessions.test.js +7 -23
- package/package.json +1 -2
- package/dist/frontend/assets/index-Bz_R9N9S.css +0 -32
- package/dist/frontend/assets/index-Yv6LVq28.js +0 -47
- package/dist/server/pty-handler.js +0 -214
- package/dist/server/sdk-handler.js +0 -536
package/dist/server/index.js
CHANGED
|
@@ -11,16 +11,27 @@ import cookieParser from 'cookie-parser';
|
|
|
11
11
|
import { loadConfig, saveConfig, DEFAULTS, readMeta, writeMeta, deleteMeta, ensureMetaDir } from './config.js';
|
|
12
12
|
import * as auth from './auth.js';
|
|
13
13
|
import * as sessions from './sessions.js';
|
|
14
|
-
import { AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, serializeAll, restoreFromDisk, activeTmuxSessionNames
|
|
14
|
+
import { AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, serializeAll, restoreFromDisk, activeTmuxSessionNames } from './sessions.js';
|
|
15
15
|
import { setupWebSocket } from './ws.js';
|
|
16
16
|
import { WorktreeWatcher, WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain, parseAllWorktrees } from './watcher.js';
|
|
17
17
|
import { isInstalled as serviceIsInstalled } from './service.js';
|
|
18
18
|
import { extensionForMime, setClipboardImage } from './clipboard.js';
|
|
19
19
|
import { listBranches } from './git.js';
|
|
20
20
|
import * as push from './push.js';
|
|
21
|
+
import { createWorkspaceRouter } from './workspaces.js';
|
|
21
22
|
const __filename = fileURLToPath(import.meta.url);
|
|
22
23
|
const __dirname = path.dirname(__filename);
|
|
23
24
|
const execFileAsync = promisify(execFile);
|
|
25
|
+
// ── Signal protection ────────────────────────────────────────────────────
|
|
26
|
+
// Ignore SIGPIPE: piped bash commands (e.g. `cmd | grep | tail`) generate
|
|
27
|
+
// SIGPIPE when the reading end of the pipe closes before the writer finishes.
|
|
28
|
+
// node-pty's native module can propagate these to PTY sessions, causing
|
|
29
|
+
// unexpected "session exited" in the browser. Ignoring SIGPIPE at the server
|
|
30
|
+
// level prevents this cascade.
|
|
31
|
+
process.on('SIGPIPE', () => { });
|
|
32
|
+
// Ignore SIGHUP: if the controlling terminal disconnects (e.g. SSH drops),
|
|
33
|
+
// keep the server and all PTY sessions alive.
|
|
34
|
+
process.on('SIGHUP', () => { });
|
|
24
35
|
// When run via CLI bin, config lives in ~/.config/claude-remote-cli/
|
|
25
36
|
// When run directly (development), fall back to local config.json
|
|
26
37
|
const CONFIG_PATH = process.env.CLAUDE_REMOTE_CONFIG || path.join(__dirname, '..', '..', 'config.json');
|
|
@@ -90,40 +101,6 @@ function promptPin(question) {
|
|
|
90
101
|
});
|
|
91
102
|
});
|
|
92
103
|
}
|
|
93
|
-
function scanReposInRoot(rootDir) {
|
|
94
|
-
const repos = [];
|
|
95
|
-
let entries;
|
|
96
|
-
try {
|
|
97
|
-
entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
|
98
|
-
}
|
|
99
|
-
catch (_) {
|
|
100
|
-
return repos;
|
|
101
|
-
}
|
|
102
|
-
for (const entry of entries) {
|
|
103
|
-
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
104
|
-
continue;
|
|
105
|
-
const fullPath = path.join(rootDir, entry.name);
|
|
106
|
-
const dotGit = path.join(fullPath, '.git');
|
|
107
|
-
try {
|
|
108
|
-
// Only count directories with a .git *directory* as repos.
|
|
109
|
-
// Worktrees and submodules have a .git *file* and should be skipped.
|
|
110
|
-
if (fs.statSync(dotGit).isDirectory()) {
|
|
111
|
-
repos.push({ name: entry.name, path: fullPath, root: rootDir });
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
catch (_) {
|
|
115
|
-
// .git doesn't exist — not a repo
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return repos;
|
|
119
|
-
}
|
|
120
|
-
function scanAllRepos(rootDirs) {
|
|
121
|
-
const repos = [];
|
|
122
|
-
for (const rootDir of rootDirs) {
|
|
123
|
-
repos.push(...scanReposInRoot(rootDir));
|
|
124
|
-
}
|
|
125
|
-
return repos;
|
|
126
|
-
}
|
|
127
104
|
function ensureGitignore(repoPath, entry) {
|
|
128
105
|
const gitignorePath = path.join(repoPath, '.gitignore');
|
|
129
106
|
try {
|
|
@@ -156,12 +133,6 @@ async function main() {
|
|
|
156
133
|
config.port = parseInt(process.env.CLAUDE_REMOTE_PORT, 10);
|
|
157
134
|
if (process.env.CLAUDE_REMOTE_HOST)
|
|
158
135
|
config.host = process.env.CLAUDE_REMOTE_HOST;
|
|
159
|
-
// Enable SDK debug logging if requested
|
|
160
|
-
if (process.env.CLAUDE_REMOTE_DEBUG_LOG === '1' || config.debugLog) {
|
|
161
|
-
const { enableDebugLog } = await import('./sdk-handler.js');
|
|
162
|
-
enableDebugLog(true);
|
|
163
|
-
console.log('SDK debug logging enabled → ~/.config/claude-remote-cli/debug/');
|
|
164
|
-
}
|
|
165
136
|
push.ensureVapidKeys(config, CONFIG_PATH, saveConfig);
|
|
166
137
|
if (!config.pinHash) {
|
|
167
138
|
const pin = await promptPin('Set up a PIN for claude-remote-cli:');
|
|
@@ -226,9 +197,12 @@ async function main() {
|
|
|
226
197
|
});
|
|
227
198
|
}
|
|
228
199
|
const watcher = new WorktreeWatcher();
|
|
229
|
-
watcher.rebuild(config.
|
|
200
|
+
watcher.rebuild(config.workspaces || []);
|
|
230
201
|
const server = http.createServer(app);
|
|
231
202
|
const { broadcastEvent } = setupWebSocket(server, authenticatedTokens, watcher);
|
|
203
|
+
// Mount workspace router
|
|
204
|
+
const workspaceRouter = createWorkspaceRouter({ configPath: CONFIG_PATH });
|
|
205
|
+
app.use('/workspaces', requireAuth, workspaceRouter);
|
|
232
206
|
// Restore sessions from a previous update restart
|
|
233
207
|
const configDir = path.dirname(CONFIG_PATH);
|
|
234
208
|
const restoredCount = await restoreFromDisk(configDir);
|
|
@@ -278,19 +252,6 @@ async function main() {
|
|
|
278
252
|
app.get('/sessions', requireAuth, (_req, res) => {
|
|
279
253
|
res.json(sessions.list());
|
|
280
254
|
});
|
|
281
|
-
// GET /repos — scan root dirs for repos
|
|
282
|
-
app.get('/repos', requireAuth, (_req, res) => {
|
|
283
|
-
const repos = scanAllRepos(config.rootDirs || []);
|
|
284
|
-
// Also include legacy manually-added repos
|
|
285
|
-
if (config.repos) {
|
|
286
|
-
for (const repo of config.repos) {
|
|
287
|
-
if (!repos.some((r) => r.path === repo.path)) {
|
|
288
|
-
repos.push(repo);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
res.json(repos);
|
|
293
|
-
});
|
|
294
255
|
// GET /branches?repo=<path> — list local and remote branches for a repo
|
|
295
256
|
app.get('/branches', requireAuth, async (req, res) => {
|
|
296
257
|
const repoPath = typeof req.query.repo === 'string' ? req.query.repo : undefined;
|
|
@@ -301,134 +262,6 @@ async function main() {
|
|
|
301
262
|
}
|
|
302
263
|
res.json(await listBranches(repoPath, { refresh }));
|
|
303
264
|
});
|
|
304
|
-
// GET /git-status?repo=<path>&branch=<name>
|
|
305
|
-
app.get('/git-status', requireAuth, async (req, res) => {
|
|
306
|
-
const repoPath = typeof req.query.repo === 'string' ? req.query.repo : undefined;
|
|
307
|
-
const branch = typeof req.query.branch === 'string' ? req.query.branch : undefined;
|
|
308
|
-
if (!repoPath || !branch) {
|
|
309
|
-
res.status(400).json({ error: 'repo and branch query parameters are required' });
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
let prState = null;
|
|
313
|
-
let additions = 0;
|
|
314
|
-
let deletions = 0;
|
|
315
|
-
// Try gh CLI for PR status
|
|
316
|
-
try {
|
|
317
|
-
const { stdout } = await execFileAsync('gh', [
|
|
318
|
-
'pr', 'view', branch,
|
|
319
|
-
'--json', 'state,additions,deletions',
|
|
320
|
-
], { cwd: repoPath });
|
|
321
|
-
const data = JSON.parse(stdout);
|
|
322
|
-
if (data.state)
|
|
323
|
-
prState = data.state.toLowerCase();
|
|
324
|
-
if (typeof data.additions === 'number')
|
|
325
|
-
additions = data.additions;
|
|
326
|
-
if (typeof data.deletions === 'number')
|
|
327
|
-
deletions = data.deletions;
|
|
328
|
-
}
|
|
329
|
-
catch {
|
|
330
|
-
// No PR or gh not available — fall back to git diff against default branch
|
|
331
|
-
try {
|
|
332
|
-
// Detect default branch (main, master, etc.)
|
|
333
|
-
let baseBranch = 'main';
|
|
334
|
-
try {
|
|
335
|
-
const { stdout: headRef } = await execFileAsync('git', [
|
|
336
|
-
'symbolic-ref', 'refs/remotes/origin/HEAD', '--short',
|
|
337
|
-
], { cwd: repoPath });
|
|
338
|
-
baseBranch = headRef.trim().replace(/^origin\//, '');
|
|
339
|
-
}
|
|
340
|
-
catch { /* use main as fallback */ }
|
|
341
|
-
const { stdout } = await execFileAsync('git', [
|
|
342
|
-
'diff', '--shortstat', baseBranch + '...' + branch,
|
|
343
|
-
], { cwd: repoPath });
|
|
344
|
-
const addMatch = stdout.match(/(\d+) insertion/);
|
|
345
|
-
const delMatch = stdout.match(/(\d+) deletion/);
|
|
346
|
-
if (addMatch)
|
|
347
|
-
additions = parseInt(addMatch[1], 10);
|
|
348
|
-
if (delMatch)
|
|
349
|
-
deletions = parseInt(delMatch[1], 10);
|
|
350
|
-
}
|
|
351
|
-
catch { /* no diff data */ }
|
|
352
|
-
}
|
|
353
|
-
res.json({ prState, additions, deletions });
|
|
354
|
-
});
|
|
355
|
-
// GET /pull-requests?repo=<path>
|
|
356
|
-
app.get('/pull-requests', requireAuth, async (req, res) => {
|
|
357
|
-
const repoPath = typeof req.query.repo === 'string' ? req.query.repo : undefined;
|
|
358
|
-
if (!repoPath) {
|
|
359
|
-
res.status(400).json({ prs: [], error: 'repo query parameter is required' });
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
const fields = 'number,title,url,headRefName,state,author,updatedAt,additions,deletions,reviewDecision';
|
|
363
|
-
// Get current GitHub user
|
|
364
|
-
let currentUser = '';
|
|
365
|
-
try {
|
|
366
|
-
const { stdout: whoami } = await execFileAsync('gh', ['api', 'user', '--jq', '.login'], { cwd: repoPath });
|
|
367
|
-
currentUser = whoami.trim();
|
|
368
|
-
}
|
|
369
|
-
catch {
|
|
370
|
-
const response = { prs: [], error: 'gh_not_authenticated' };
|
|
371
|
-
res.json(response);
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
// Fetch authored PRs
|
|
375
|
-
const authored = [];
|
|
376
|
-
try {
|
|
377
|
-
const { stdout } = await execFileAsync('gh', [
|
|
378
|
-
'pr', 'list', '--author', currentUser, '--state', 'open', '--limit', '30',
|
|
379
|
-
'--json', fields,
|
|
380
|
-
], { cwd: repoPath });
|
|
381
|
-
const raw = JSON.parse(stdout);
|
|
382
|
-
for (const pr of raw) {
|
|
383
|
-
authored.push({
|
|
384
|
-
number: pr.number,
|
|
385
|
-
title: pr.title,
|
|
386
|
-
url: pr.url,
|
|
387
|
-
headRefName: pr.headRefName,
|
|
388
|
-
state: pr.state,
|
|
389
|
-
author: pr.author?.login ?? currentUser,
|
|
390
|
-
role: 'author',
|
|
391
|
-
updatedAt: pr.updatedAt,
|
|
392
|
-
additions: pr.additions ?? 0,
|
|
393
|
-
deletions: pr.deletions ?? 0,
|
|
394
|
-
reviewDecision: pr.reviewDecision ?? null,
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
catch { /* no authored PRs or gh error */ }
|
|
399
|
-
// Fetch review-requested PRs
|
|
400
|
-
const reviewing = [];
|
|
401
|
-
try {
|
|
402
|
-
const { stdout } = await execFileAsync('gh', [
|
|
403
|
-
'pr', 'list', '--search', `review-requested:${currentUser}`, '--state', 'open', '--limit', '30',
|
|
404
|
-
'--json', fields,
|
|
405
|
-
], { cwd: repoPath });
|
|
406
|
-
const raw = JSON.parse(stdout);
|
|
407
|
-
for (const pr of raw) {
|
|
408
|
-
reviewing.push({
|
|
409
|
-
number: pr.number,
|
|
410
|
-
title: pr.title,
|
|
411
|
-
url: pr.url,
|
|
412
|
-
headRefName: pr.headRefName,
|
|
413
|
-
state: pr.state,
|
|
414
|
-
author: pr.author?.login ?? '',
|
|
415
|
-
role: 'reviewer',
|
|
416
|
-
updatedAt: pr.updatedAt,
|
|
417
|
-
additions: pr.additions ?? 0,
|
|
418
|
-
deletions: pr.deletions ?? 0,
|
|
419
|
-
reviewDecision: pr.reviewDecision ?? null,
|
|
420
|
-
});
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
catch { /* no review-requested PRs or gh error */ }
|
|
424
|
-
// Deduplicate: if a PR appears in both (user is author AND reviewer), keep as 'author'
|
|
425
|
-
const seen = new Set(authored.map(pr => pr.number));
|
|
426
|
-
const combined = [...authored, ...reviewing.filter(pr => !seen.has(pr.number))];
|
|
427
|
-
// Sort by updatedAt descending
|
|
428
|
-
combined.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
429
|
-
const response = { prs: combined };
|
|
430
|
-
res.json(response);
|
|
431
|
-
});
|
|
432
265
|
// GET /worktrees?repo=<path> — list worktrees; omit repo to scan all repos in all rootDirs
|
|
433
266
|
app.get('/worktrees', requireAuth, async (req, res) => {
|
|
434
267
|
const repoParam = typeof req.query.repo === 'string' ? req.query.repo : undefined;
|
|
@@ -440,7 +273,30 @@ async function main() {
|
|
|
440
273
|
reposToScan = [{ path: repoParam, name: repoParam.split('/').filter(Boolean).pop() || '', root }];
|
|
441
274
|
}
|
|
442
275
|
else {
|
|
443
|
-
reposToScan =
|
|
276
|
+
reposToScan = [];
|
|
277
|
+
for (const rootDir of roots) {
|
|
278
|
+
let entries;
|
|
279
|
+
try {
|
|
280
|
+
entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
|
281
|
+
}
|
|
282
|
+
catch (_) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
for (const entry of entries) {
|
|
286
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
287
|
+
continue;
|
|
288
|
+
const fullPath = path.join(rootDir, entry.name);
|
|
289
|
+
const dotGit = path.join(fullPath, '.git');
|
|
290
|
+
try {
|
|
291
|
+
if (fs.statSync(dotGit).isDirectory()) {
|
|
292
|
+
reposToScan.push({ name: entry.name, path: fullPath, root: rootDir });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch (_) {
|
|
296
|
+
// .git doesn't exist — not a repo
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
444
300
|
}
|
|
445
301
|
for (const repo of reposToScan) {
|
|
446
302
|
// Use git worktree list to discover all worktrees (including those at arbitrary paths)
|
|
@@ -502,42 +358,6 @@ async function main() {
|
|
|
502
358
|
});
|
|
503
359
|
res.json(unique);
|
|
504
360
|
});
|
|
505
|
-
// GET /roots — list root directories
|
|
506
|
-
app.get('/roots', requireAuth, (_req, res) => {
|
|
507
|
-
res.json(config.rootDirs || []);
|
|
508
|
-
});
|
|
509
|
-
// POST /roots — add a root directory
|
|
510
|
-
app.post('/roots', requireAuth, (req, res) => {
|
|
511
|
-
const { path: rootPath } = req.body;
|
|
512
|
-
if (!rootPath) {
|
|
513
|
-
res.status(400).json({ error: 'path is required' });
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
if (!config.rootDirs)
|
|
517
|
-
config.rootDirs = [];
|
|
518
|
-
if (config.rootDirs.includes(rootPath)) {
|
|
519
|
-
res.status(409).json({ error: 'Root already exists' });
|
|
520
|
-
return;
|
|
521
|
-
}
|
|
522
|
-
config.rootDirs.push(rootPath);
|
|
523
|
-
saveConfig(CONFIG_PATH, config);
|
|
524
|
-
watcher.rebuild(config.rootDirs);
|
|
525
|
-
broadcastEvent('worktrees-changed');
|
|
526
|
-
res.status(201).json(config.rootDirs);
|
|
527
|
-
});
|
|
528
|
-
// DELETE /roots — remove a root directory
|
|
529
|
-
app.delete('/roots', requireAuth, (req, res) => {
|
|
530
|
-
const { path: rootPath } = req.body;
|
|
531
|
-
if (!rootPath || !config.rootDirs) {
|
|
532
|
-
res.status(400).json({ error: 'path is required' });
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
config.rootDirs = config.rootDirs.filter((r) => r !== rootPath);
|
|
536
|
-
saveConfig(CONFIG_PATH, config);
|
|
537
|
-
watcher.rebuild(config.rootDirs);
|
|
538
|
-
broadcastEvent('worktrees-changed');
|
|
539
|
-
res.json(config.rootDirs);
|
|
540
|
-
});
|
|
541
361
|
// GET /config/defaultAgent — get default coding agent
|
|
542
362
|
app.get('/config/defaultAgent', requireAuth, (_req, res) => {
|
|
543
363
|
res.json({ defaultAgent: config.defaultAgent || 'claude' });
|
|
@@ -612,13 +432,7 @@ async function main() {
|
|
|
612
432
|
return;
|
|
613
433
|
}
|
|
614
434
|
}
|
|
615
|
-
//
|
|
616
|
-
const activeSessions = sessions.list();
|
|
617
|
-
const conflict = activeSessions.find(function (s) { return s.repoPath === worktreePath; });
|
|
618
|
-
if (conflict) {
|
|
619
|
-
res.status(409).json({ error: 'Close the active session first' });
|
|
620
|
-
return;
|
|
621
|
-
}
|
|
435
|
+
// Multiple sessions per worktree allowed (multi-tab support)
|
|
622
436
|
// Derive branch name from metadata or worktree directory name
|
|
623
437
|
const meta = readMeta(CONFIG_PATH, worktreePath);
|
|
624
438
|
const branchName = (meta && meta.branchName) || worktreePath.split('/').pop() || '';
|
|
@@ -662,7 +476,7 @@ async function main() {
|
|
|
662
476
|
});
|
|
663
477
|
// POST /sessions
|
|
664
478
|
app.post('/sessions', requireAuth, async (req, res) => {
|
|
665
|
-
const { repoPath, repoName, worktreePath, branchName, claudeArgs, yolo, agent, useTmux } = req.body;
|
|
479
|
+
const { repoPath, repoName, worktreePath, branchName, claudeArgs, yolo, agent, useTmux, needsBranchRename, branchRenamePrompt } = req.body;
|
|
666
480
|
if (!repoPath) {
|
|
667
481
|
res.status(400).json({ error: 'repoPath is required' });
|
|
668
482
|
return;
|
|
@@ -683,8 +497,11 @@ async function main() {
|
|
|
683
497
|
let sessionRepoPath;
|
|
684
498
|
let resolvedBranch = '';
|
|
685
499
|
if (worktreePath) {
|
|
686
|
-
//
|
|
687
|
-
|
|
500
|
+
// Only use --continue if:
|
|
501
|
+
// 1. Not a brand-new worktree (needsBranchRename flag)
|
|
502
|
+
// 2. A prior Claude session exists in this directory (.claude/ dir present)
|
|
503
|
+
const hasPriorSession = !needsBranchRename && fs.existsSync(path.join(worktreePath, '.claude'));
|
|
504
|
+
args = hasPriorSession ? [...AGENT_CONTINUE_ARGS[resolvedAgent], ...baseArgs] : [...baseArgs];
|
|
688
505
|
cwd = worktreePath;
|
|
689
506
|
sessionRepoPath = worktreePath;
|
|
690
507
|
worktreeName = worktreePath.split('/').pop() || '';
|
|
@@ -816,6 +633,8 @@ async function main() {
|
|
|
816
633
|
args,
|
|
817
634
|
configPath: CONFIG_PATH,
|
|
818
635
|
useTmux: useTmux ?? config.launchInTmux,
|
|
636
|
+
needsBranchRename: needsBranchRename ?? false,
|
|
637
|
+
branchRenamePrompt: branchRenamePrompt ?? '',
|
|
819
638
|
});
|
|
820
639
|
if (!worktreePath) {
|
|
821
640
|
writeMeta(CONFIG_PATH, {
|
|
@@ -835,12 +654,7 @@ async function main() {
|
|
|
835
654
|
return;
|
|
836
655
|
}
|
|
837
656
|
const resolvedAgent = agent || config.defaultAgent || 'claude';
|
|
838
|
-
//
|
|
839
|
-
const existing = sessions.findRepoSession(repoPath);
|
|
840
|
-
if (existing) {
|
|
841
|
-
res.status(409).json({ error: 'A session already exists for this repo', sessionId: existing.id });
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
657
|
+
// Multiple sessions per repo allowed (multi-tab support)
|
|
844
658
|
const name = repoName || repoPath.split('/').filter(Boolean).pop() || 'session';
|
|
845
659
|
const baseArgs = [
|
|
846
660
|
...(config.claudeArgs || []),
|
|
@@ -890,58 +704,6 @@ async function main() {
|
|
|
890
704
|
res.status(404).json({ error: 'Session not found' });
|
|
891
705
|
}
|
|
892
706
|
});
|
|
893
|
-
// POST /sessions/:id/message — send message to SDK session
|
|
894
|
-
app.post('/sessions/:id/message', requireAuth, (req, res) => {
|
|
895
|
-
const id = req.params['id'];
|
|
896
|
-
const { text } = req.body;
|
|
897
|
-
if (!text) {
|
|
898
|
-
res.status(400).json({ error: 'text is required' });
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
const session = sessions.get(id);
|
|
902
|
-
if (!session) {
|
|
903
|
-
res.status(404).json({ error: 'Session not found' });
|
|
904
|
-
return;
|
|
905
|
-
}
|
|
906
|
-
if (session.mode !== 'sdk') {
|
|
907
|
-
res.status(400).json({ error: 'Session is not an SDK session — use WebSocket for PTY sessions' });
|
|
908
|
-
return;
|
|
909
|
-
}
|
|
910
|
-
try {
|
|
911
|
-
sessions.write(id, text);
|
|
912
|
-
res.json({ ok: true });
|
|
913
|
-
}
|
|
914
|
-
catch (err) {
|
|
915
|
-
const message = err instanceof Error ? err.message : 'Failed to send message';
|
|
916
|
-
res.status(500).json({ error: message });
|
|
917
|
-
}
|
|
918
|
-
});
|
|
919
|
-
// POST /sessions/:id/permission — handle permission approval for SDK session
|
|
920
|
-
app.post('/sessions/:id/permission', requireAuth, (req, res) => {
|
|
921
|
-
const id = req.params['id'];
|
|
922
|
-
const { requestId, approved } = req.body;
|
|
923
|
-
if (!requestId || typeof approved !== 'boolean') {
|
|
924
|
-
res.status(400).json({ error: 'requestId and approved are required' });
|
|
925
|
-
return;
|
|
926
|
-
}
|
|
927
|
-
const session = sessions.get(id);
|
|
928
|
-
if (!session) {
|
|
929
|
-
res.status(404).json({ error: 'Session not found' });
|
|
930
|
-
return;
|
|
931
|
-
}
|
|
932
|
-
if (session.mode !== 'sdk') {
|
|
933
|
-
res.status(400).json({ error: 'Session is not an SDK session' });
|
|
934
|
-
return;
|
|
935
|
-
}
|
|
936
|
-
try {
|
|
937
|
-
sessions.handlePermission(id, requestId, approved);
|
|
938
|
-
res.json({ ok: true });
|
|
939
|
-
}
|
|
940
|
-
catch (err) {
|
|
941
|
-
const message = err instanceof Error ? err.message : 'Failed to handle permission';
|
|
942
|
-
res.status(500).json({ error: message });
|
|
943
|
-
}
|
|
944
|
-
});
|
|
945
707
|
// PATCH /sessions/:id — update displayName and persist to metadata
|
|
946
708
|
app.patch('/sessions/:id', requireAuth, (req, res) => {
|
|
947
709
|
const { displayName } = req.body;
|
|
@@ -1049,15 +811,12 @@ async function main() {
|
|
|
1049
811
|
catch {
|
|
1050
812
|
// tmux not installed or no sessions — ignore
|
|
1051
813
|
}
|
|
1052
|
-
// Start SDK idle sweep
|
|
1053
|
-
startSdkIdleSweep();
|
|
1054
814
|
function gracefulShutdown() {
|
|
1055
815
|
server.close();
|
|
1056
|
-
stopSdkIdleSweep();
|
|
1057
816
|
// Serialize sessions to disk BEFORE killing them
|
|
1058
817
|
const configDir = path.dirname(CONFIG_PATH);
|
|
1059
818
|
serializeAll(configDir);
|
|
1060
|
-
// Kill all active sessions (PTY + tmux
|
|
819
|
+
// Kill all active sessions (PTY + tmux)
|
|
1061
820
|
for (const s of sessions.list()) {
|
|
1062
821
|
try {
|
|
1063
822
|
sessions.kill(s.id);
|
package/dist/server/push.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import webpush from 'web-push';
|
|
2
2
|
let vapidPublicKey = null;
|
|
3
3
|
const subscriptions = new Map();
|
|
4
|
-
const MAX_PAYLOAD_SIZE = 4 * 1024; // 4KB
|
|
5
4
|
export function ensureVapidKeys(config, configPath, save) {
|
|
6
5
|
if (config.vapidPublicKey && config.vapidPrivateKey) {
|
|
7
6
|
vapidPublicKey = config.vapidPublicKey;
|
|
@@ -40,65 +39,15 @@ export function removeSession(sessionId) {
|
|
|
40
39
|
entry.sessionIds.delete(sessionId);
|
|
41
40
|
}
|
|
42
41
|
}
|
|
43
|
-
export function
|
|
44
|
-
try {
|
|
45
|
-
switch (event.type) {
|
|
46
|
-
case 'tool_call': {
|
|
47
|
-
const action = event.toolName || 'use a tool';
|
|
48
|
-
const target = event.path || (event.toolInput && typeof event.toolInput === 'object'
|
|
49
|
-
? event.toolInput.file_path || event.toolInput.command || ''
|
|
50
|
-
: '');
|
|
51
|
-
const msg = target
|
|
52
|
-
? `Claude wants to ${action} ${target}`
|
|
53
|
-
: `Claude wants to ${action}`;
|
|
54
|
-
return msg.slice(0, 200);
|
|
55
|
-
}
|
|
56
|
-
case 'turn_completed':
|
|
57
|
-
return 'Claude finished';
|
|
58
|
-
case 'error': {
|
|
59
|
-
const brief = (event.text || 'unknown error').slice(0, 150);
|
|
60
|
-
return `Claude hit an error: ${brief}`;
|
|
61
|
-
}
|
|
62
|
-
default:
|
|
63
|
-
return 'Claude is waiting for your input';
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
catch {
|
|
67
|
-
return 'Claude is waiting for your input';
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
function truncatePayload(payload) {
|
|
71
|
-
if (payload.length <= MAX_PAYLOAD_SIZE)
|
|
72
|
-
return payload;
|
|
73
|
-
// Try to parse, truncate text fields, and re-serialize
|
|
74
|
-
try {
|
|
75
|
-
const obj = JSON.parse(payload);
|
|
76
|
-
if (typeof obj.enrichedMessage === 'string' && obj.enrichedMessage.length > 100) {
|
|
77
|
-
obj.enrichedMessage = obj.enrichedMessage.slice(0, 100) + '...';
|
|
78
|
-
}
|
|
79
|
-
const truncated = JSON.stringify(obj);
|
|
80
|
-
if (truncated.length <= MAX_PAYLOAD_SIZE)
|
|
81
|
-
return truncated;
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
// fall through
|
|
85
|
-
}
|
|
86
|
-
return payload.slice(0, MAX_PAYLOAD_SIZE);
|
|
87
|
-
}
|
|
88
|
-
export function notifySessionIdle(sessionId, session, sdkEvent) {
|
|
42
|
+
export function notifySessionIdle(sessionId, session) {
|
|
89
43
|
if (!vapidPublicKey)
|
|
90
44
|
return;
|
|
91
|
-
const
|
|
92
|
-
const payloadObj = {
|
|
45
|
+
const payload = JSON.stringify({
|
|
93
46
|
type: 'session-attention',
|
|
94
47
|
sessionId,
|
|
95
48
|
displayName: session.displayName,
|
|
96
49
|
sessionType: session.type,
|
|
97
|
-
};
|
|
98
|
-
if (enrichedMessage) {
|
|
99
|
-
payloadObj.enrichedMessage = enrichedMessage;
|
|
100
|
-
}
|
|
101
|
-
const payload = truncatePayload(JSON.stringify(payloadObj));
|
|
50
|
+
});
|
|
102
51
|
for (const [endpoint, entry] of subscriptions) {
|
|
103
52
|
if (!entry.sessionIds.has(sessionId))
|
|
104
53
|
continue;
|