claude-remote-cli 3.0.3 → 3.0.5
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-BgPZneAz.js +47 -0
- package/dist/frontend/assets/index-C0iHLowo.css +32 -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-BgOmCV-k.css +0 -32
- package/dist/frontend/assets/index-CKQHbnTN.js +0 -47
- package/dist/server/pty-handler.js +0 -214
- package/dist/server/sdk-handler.js +0 -536
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { Router } from 'express';
|
|
6
|
+
import { loadConfig, saveConfig, getWorkspaceSettings, setWorkspaceSettings } from './config.js';
|
|
7
|
+
import { listBranches, getActivityFeed, getCiStatus, getPrForBranch, switchBranch, getCurrentBranch } from './git.js';
|
|
8
|
+
import { MOUNTAIN_NAMES } from './types.js';
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Exported helpers
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
/**
|
|
14
|
+
* Resolves and validates a raw workspace path string.
|
|
15
|
+
* Throws with a human-readable message if the path is invalid.
|
|
16
|
+
*/
|
|
17
|
+
export async function validateWorkspacePath(rawPath) {
|
|
18
|
+
if (!rawPath || typeof rawPath !== 'string') {
|
|
19
|
+
throw new Error('Path must be a non-empty string');
|
|
20
|
+
}
|
|
21
|
+
const resolved = path.resolve(rawPath);
|
|
22
|
+
let stat;
|
|
23
|
+
try {
|
|
24
|
+
stat = await fs.promises.stat(resolved);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new Error(`Path does not exist: ${resolved}`);
|
|
28
|
+
}
|
|
29
|
+
if (!stat.isDirectory()) {
|
|
30
|
+
throw new Error(`Path is not a directory: ${resolved}`);
|
|
31
|
+
}
|
|
32
|
+
return resolved;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Detects whether a directory is the root of a git repository and, if so,
|
|
36
|
+
* what the default branch name is.
|
|
37
|
+
*/
|
|
38
|
+
export async function detectGitRepo(dirPath, execAsync = execFileAsync) {
|
|
39
|
+
try {
|
|
40
|
+
await execAsync('git', ['rev-parse', '--git-dir'], { cwd: dirPath });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return { isGitRepo: false, defaultBranch: null };
|
|
44
|
+
}
|
|
45
|
+
// Attempt to determine the default branch from remote HEAD
|
|
46
|
+
let defaultBranch = null;
|
|
47
|
+
try {
|
|
48
|
+
const { stdout } = await execAsync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'], { cwd: dirPath });
|
|
49
|
+
const trimmed = stdout.trim();
|
|
50
|
+
// "origin/main" → "main"
|
|
51
|
+
defaultBranch = trimmed.replace(/^origin\//, '') || null;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Fall back to checking local HEAD
|
|
55
|
+
try {
|
|
56
|
+
const { stdout } = await execAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: dirPath });
|
|
57
|
+
defaultBranch = stdout.trim() || null;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Cannot determine default branch
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { isGitRepo: true, defaultBranch };
|
|
64
|
+
}
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Router factory
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
/**
|
|
69
|
+
* Creates and returns an Express Router that handles all /workspaces routes.
|
|
70
|
+
*
|
|
71
|
+
* Caller is responsible for mounting and applying auth middleware:
|
|
72
|
+
* app.use('/workspaces', requireAuth, createWorkspaceRouter({ configPath }));
|
|
73
|
+
*/
|
|
74
|
+
export function createWorkspaceRouter(deps) {
|
|
75
|
+
const { configPath } = deps;
|
|
76
|
+
const exec = deps.execAsync ?? execFileAsync;
|
|
77
|
+
const router = Router();
|
|
78
|
+
// Helper: reload config on every request so concurrent changes are reflected
|
|
79
|
+
function getConfig() {
|
|
80
|
+
return loadConfig(configPath);
|
|
81
|
+
}
|
|
82
|
+
// -------------------------------------------------------------------------
|
|
83
|
+
// GET /workspaces — list all workspaces with git info
|
|
84
|
+
// -------------------------------------------------------------------------
|
|
85
|
+
router.get('/', async (_req, res) => {
|
|
86
|
+
const config = getConfig();
|
|
87
|
+
const workspacePaths = config.workspaces ?? [];
|
|
88
|
+
const results = await Promise.all(workspacePaths.map(async (p) => {
|
|
89
|
+
const name = path.basename(p);
|
|
90
|
+
const { isGitRepo, defaultBranch } = await detectGitRepo(p, exec);
|
|
91
|
+
return { path: p, name, isGitRepo, defaultBranch };
|
|
92
|
+
}));
|
|
93
|
+
res.json({ workspaces: results });
|
|
94
|
+
});
|
|
95
|
+
// -------------------------------------------------------------------------
|
|
96
|
+
// POST /workspaces — add a workspace
|
|
97
|
+
// -------------------------------------------------------------------------
|
|
98
|
+
router.post('/', async (req, res) => {
|
|
99
|
+
const body = req.body;
|
|
100
|
+
const rawPath = body.path;
|
|
101
|
+
if (typeof rawPath !== 'string' || !rawPath) {
|
|
102
|
+
res.status(400).json({ error: 'path is required' });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
let resolved;
|
|
106
|
+
try {
|
|
107
|
+
resolved = await validateWorkspacePath(rawPath);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const config = getConfig();
|
|
114
|
+
const workspaces = config.workspaces ?? [];
|
|
115
|
+
if (workspaces.includes(resolved)) {
|
|
116
|
+
res.status(409).json({ error: 'Workspace already exists' });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const { isGitRepo, defaultBranch } = await detectGitRepo(resolved, exec);
|
|
120
|
+
config.workspaces = [...workspaces, resolved];
|
|
121
|
+
// Store detected default branch in per-workspace settings
|
|
122
|
+
if (isGitRepo && defaultBranch) {
|
|
123
|
+
if (!config.workspaceSettings)
|
|
124
|
+
config.workspaceSettings = {};
|
|
125
|
+
config.workspaceSettings[resolved] = {
|
|
126
|
+
...config.workspaceSettings[resolved],
|
|
127
|
+
defaultBranch,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
saveConfig(configPath, config);
|
|
131
|
+
const workspace = {
|
|
132
|
+
path: resolved,
|
|
133
|
+
name: path.basename(resolved),
|
|
134
|
+
isGitRepo,
|
|
135
|
+
defaultBranch,
|
|
136
|
+
};
|
|
137
|
+
res.status(201).json(workspace);
|
|
138
|
+
});
|
|
139
|
+
// -------------------------------------------------------------------------
|
|
140
|
+
// DELETE /workspaces — remove a workspace
|
|
141
|
+
// -------------------------------------------------------------------------
|
|
142
|
+
router.delete('/', async (req, res) => {
|
|
143
|
+
const body = req.body;
|
|
144
|
+
const rawPath = body.path;
|
|
145
|
+
if (typeof rawPath !== 'string' || !rawPath) {
|
|
146
|
+
res.status(400).json({ error: 'path is required' });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const resolved = path.resolve(rawPath);
|
|
150
|
+
const config = getConfig();
|
|
151
|
+
const workspaces = config.workspaces ?? [];
|
|
152
|
+
const idx = workspaces.indexOf(resolved);
|
|
153
|
+
if (idx === -1) {
|
|
154
|
+
res.status(404).json({ error: 'Workspace not found' });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
config.workspaces = workspaces.filter((p) => p !== resolved);
|
|
158
|
+
saveConfig(configPath, config);
|
|
159
|
+
res.json({ removed: resolved });
|
|
160
|
+
});
|
|
161
|
+
// -------------------------------------------------------------------------
|
|
162
|
+
// GET /workspaces/dashboard — aggregated PR + activity data for a workspace
|
|
163
|
+
// -------------------------------------------------------------------------
|
|
164
|
+
router.get('/dashboard', async (req, res) => {
|
|
165
|
+
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
166
|
+
if (!workspacePath) {
|
|
167
|
+
res.status(400).json({ error: 'path query parameter is required' });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const fields = 'number,title,url,headRefName,state,author,updatedAt,additions,deletions,reviewDecision,mergeable,mergeStateStatus';
|
|
171
|
+
// Get current GitHub user
|
|
172
|
+
let currentUser = '';
|
|
173
|
+
try {
|
|
174
|
+
const { stdout: whoami } = await exec('gh', ['api', 'user', '--jq', '.login'], { cwd: workspacePath });
|
|
175
|
+
currentUser = whoami.trim();
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
const response = { prs: [], error: 'gh_not_authenticated' };
|
|
179
|
+
res.json({ pullRequests: response, branches: [] });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Helper to map raw gh JSON to PullRequest
|
|
183
|
+
function mapRawPr(raw, role, fallbackAuthor) {
|
|
184
|
+
return {
|
|
185
|
+
number: raw.number,
|
|
186
|
+
title: raw.title,
|
|
187
|
+
url: raw.url,
|
|
188
|
+
headRefName: raw.headRefName,
|
|
189
|
+
state: raw.state,
|
|
190
|
+
author: raw.author?.login ?? fallbackAuthor,
|
|
191
|
+
role,
|
|
192
|
+
updatedAt: raw.updatedAt,
|
|
193
|
+
additions: raw.additions ?? 0,
|
|
194
|
+
deletions: raw.deletions ?? 0,
|
|
195
|
+
reviewDecision: raw.reviewDecision ?? null,
|
|
196
|
+
mergeable: raw.mergeable ?? null,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// Fetch authored + review-requested PRs in parallel
|
|
200
|
+
const [authored, reviewing] = await Promise.all([
|
|
201
|
+
(async () => {
|
|
202
|
+
try {
|
|
203
|
+
const { stdout } = await exec('gh', ['pr', 'list', '--author', currentUser, '--state', 'open', '--limit', '30', '--json', fields], { cwd: workspacePath });
|
|
204
|
+
return JSON.parse(stdout).map(pr => mapRawPr(pr, 'author', currentUser));
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
})(),
|
|
210
|
+
(async () => {
|
|
211
|
+
try {
|
|
212
|
+
const { stdout } = await exec('gh', ['pr', 'list', '--search', `review-requested:${currentUser}`, '--state', 'open', '--limit', '30', '--json', fields], { cwd: workspacePath });
|
|
213
|
+
return JSON.parse(stdout).map(pr => mapRawPr(pr, 'reviewer', ''));
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
})(),
|
|
219
|
+
]);
|
|
220
|
+
// Deduplicate: if a PR appears in both, keep as 'author'
|
|
221
|
+
const seen = new Set(authored.map((pr) => pr.number));
|
|
222
|
+
const combined = [...authored, ...reviewing.filter((pr) => !seen.has(pr.number))];
|
|
223
|
+
// Sort by updatedAt descending
|
|
224
|
+
combined.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
225
|
+
const pullRequests = { prs: combined };
|
|
226
|
+
// Fetch branches for the workspace
|
|
227
|
+
let branches = [];
|
|
228
|
+
try {
|
|
229
|
+
branches = await listBranches(workspacePath);
|
|
230
|
+
}
|
|
231
|
+
catch { /* not a git repo or git unavailable */ }
|
|
232
|
+
// Fetch recent activity
|
|
233
|
+
let activity = [];
|
|
234
|
+
try {
|
|
235
|
+
activity = await getActivityFeed(workspacePath);
|
|
236
|
+
}
|
|
237
|
+
catch { /* git log unavailable */ }
|
|
238
|
+
res.json({
|
|
239
|
+
pullRequests,
|
|
240
|
+
branches,
|
|
241
|
+
activity,
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
// -------------------------------------------------------------------------
|
|
245
|
+
// GET /workspaces/settings — per-workspace settings
|
|
246
|
+
// -------------------------------------------------------------------------
|
|
247
|
+
router.get('/settings', async (req, res) => {
|
|
248
|
+
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
249
|
+
if (!workspacePath) {
|
|
250
|
+
res.status(400).json({ error: 'path query parameter is required' });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const config = getConfig();
|
|
254
|
+
const resolved = path.resolve(workspacePath);
|
|
255
|
+
const settings = config.workspaceSettings?.[resolved] ?? {};
|
|
256
|
+
res.json(settings);
|
|
257
|
+
});
|
|
258
|
+
// -------------------------------------------------------------------------
|
|
259
|
+
// PATCH /workspaces/settings — update per-workspace settings
|
|
260
|
+
// -------------------------------------------------------------------------
|
|
261
|
+
router.patch('/settings', async (req, res) => {
|
|
262
|
+
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
263
|
+
if (!workspacePath) {
|
|
264
|
+
res.status(400).json({ error: 'path query parameter is required' });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const resolved = path.resolve(workspacePath);
|
|
268
|
+
const updates = req.body;
|
|
269
|
+
const config = getConfig();
|
|
270
|
+
const current = config.workspaceSettings?.[resolved] ?? {};
|
|
271
|
+
const merged = { ...current, ...updates };
|
|
272
|
+
config.workspaceSettings = { ...config.workspaceSettings, [resolved]: merged };
|
|
273
|
+
saveConfig(configPath, config);
|
|
274
|
+
res.json(merged);
|
|
275
|
+
});
|
|
276
|
+
// -------------------------------------------------------------------------
|
|
277
|
+
// GET /workspaces/pr — PR info for a specific branch
|
|
278
|
+
// -------------------------------------------------------------------------
|
|
279
|
+
router.get('/pr', async (req, res) => {
|
|
280
|
+
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
281
|
+
const branch = typeof req.query.branch === 'string' ? req.query.branch : undefined;
|
|
282
|
+
if (!workspacePath || !branch) {
|
|
283
|
+
res.status(400).json({ error: 'path and branch query parameters are required' });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
const pr = await getPrForBranch(workspacePath, branch);
|
|
288
|
+
if (pr) {
|
|
289
|
+
res.json(pr);
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
res.status(404).json({ error: 'No PR found for branch' });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
res.status(404).json({ error: 'No PR found for branch' });
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
// -------------------------------------------------------------------------
|
|
300
|
+
// GET /workspaces/ci-status — CI check results for a workspace + branch
|
|
301
|
+
// -------------------------------------------------------------------------
|
|
302
|
+
router.get('/ci-status', async (req, res) => {
|
|
303
|
+
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
304
|
+
const branch = typeof req.query.branch === 'string' ? req.query.branch : undefined;
|
|
305
|
+
if (!workspacePath) {
|
|
306
|
+
res.status(400).json({ error: 'path query parameter is required' });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const status = await getCiStatus(workspacePath, branch ?? 'HEAD');
|
|
311
|
+
res.json(status);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
res.json({ total: 0, passing: 0, failing: 0, pending: 0 });
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
// -------------------------------------------------------------------------
|
|
318
|
+
// POST /workspaces/branch — switch branch for a workspace
|
|
319
|
+
// -------------------------------------------------------------------------
|
|
320
|
+
router.post('/branch', async (req, res) => {
|
|
321
|
+
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
322
|
+
if (!workspacePath) {
|
|
323
|
+
res.status(400).json({ error: 'path query parameter is required' });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const body = req.body;
|
|
327
|
+
const branch = body.branch;
|
|
328
|
+
if (typeof branch !== 'string' || !branch) {
|
|
329
|
+
res.status(400).json({ error: 'branch is required in request body' });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const result = await switchBranch(workspacePath, branch);
|
|
333
|
+
if (result.success) {
|
|
334
|
+
res.json({ path: workspacePath, branch });
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
res.status(400).json({ error: result.error ?? `Failed to switch to branch: ${branch}` });
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
// -------------------------------------------------------------------------
|
|
341
|
+
// POST /workspaces/worktree — create a new worktree with the next mountain name
|
|
342
|
+
// -------------------------------------------------------------------------
|
|
343
|
+
router.post('/worktree', async (req, res) => {
|
|
344
|
+
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
345
|
+
if (!workspacePath) {
|
|
346
|
+
res.status(400).json({ error: 'path query parameter is required' });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const resolved = path.resolve(workspacePath);
|
|
350
|
+
const config = getConfig();
|
|
351
|
+
const settings = getWorkspaceSettings(config, resolved);
|
|
352
|
+
// Get next mountain name
|
|
353
|
+
const index = settings.nextMountainIndex ?? 0;
|
|
354
|
+
const mountainName = MOUNTAIN_NAMES[index % MOUNTAIN_NAMES.length] ?? 'everest';
|
|
355
|
+
// Build branch name with optional prefix
|
|
356
|
+
const prefix = settings.branchPrefix ?? '';
|
|
357
|
+
const branchName = prefix + mountainName;
|
|
358
|
+
// Detect base branch: user setting > git detected > fallback
|
|
359
|
+
let baseBranch = settings.defaultBranch;
|
|
360
|
+
if (!baseBranch) {
|
|
361
|
+
const detected = await detectGitRepo(resolved);
|
|
362
|
+
baseBranch = detected.defaultBranch ?? 'main';
|
|
363
|
+
}
|
|
364
|
+
// Create git worktree at <workspacePath>/.worktrees/<mountainName>
|
|
365
|
+
const worktreePath = path.join(resolved, '.worktrees', mountainName);
|
|
366
|
+
try {
|
|
367
|
+
// Ensure .worktrees/ is in .gitignore
|
|
368
|
+
const gitignorePath = path.join(resolved, '.gitignore');
|
|
369
|
+
try {
|
|
370
|
+
const existing = await fs.promises.readFile(gitignorePath, 'utf8');
|
|
371
|
+
if (!existing.includes('.worktrees/')) {
|
|
372
|
+
await fs.promises.appendFile(gitignorePath, '\n.worktrees/\n');
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
await fs.promises.writeFile(gitignorePath, '.worktrees/\n');
|
|
377
|
+
}
|
|
378
|
+
await exec('git', ['worktree', 'add', '-b', branchName, worktreePath, baseBranch], { cwd: resolved });
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
382
|
+
res.status(500).json({ error: `Failed to create worktree: ${msg}` });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// Increment mountain counter AFTER successful creation (don't skip names on failure)
|
|
386
|
+
setWorkspaceSettings(configPath, config, resolved, {
|
|
387
|
+
nextMountainIndex: index + 1,
|
|
388
|
+
});
|
|
389
|
+
res.json({ branchName, mountainName, worktreePath });
|
|
390
|
+
});
|
|
391
|
+
// -------------------------------------------------------------------------
|
|
392
|
+
// GET /workspaces/current-branch — current checked-out branch for a path
|
|
393
|
+
// -------------------------------------------------------------------------
|
|
394
|
+
router.get('/current-branch', async (req, res) => {
|
|
395
|
+
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
396
|
+
if (!workspacePath) {
|
|
397
|
+
res.status(400).json({ error: 'path query parameter is required' });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const branch = await getCurrentBranch(path.resolve(workspacePath));
|
|
401
|
+
res.json({ branch });
|
|
402
|
+
});
|
|
403
|
+
// -------------------------------------------------------------------------
|
|
404
|
+
// GET /workspaces/autocomplete — path prefix autocomplete
|
|
405
|
+
// -------------------------------------------------------------------------
|
|
406
|
+
router.get('/autocomplete', async (req, res) => {
|
|
407
|
+
const prefix = typeof req.query.prefix === 'string' ? req.query.prefix : '';
|
|
408
|
+
if (!prefix) {
|
|
409
|
+
res.json({ suggestions: [] });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const expanded = prefix.startsWith('~')
|
|
413
|
+
? path.join(process.env.HOME ?? '~', prefix.slice(1))
|
|
414
|
+
: prefix;
|
|
415
|
+
let dirToRead;
|
|
416
|
+
let partialName;
|
|
417
|
+
if (expanded.endsWith('/') || expanded.endsWith(path.sep)) {
|
|
418
|
+
// User typed a trailing slash — list immediate children of that dir
|
|
419
|
+
dirToRead = expanded;
|
|
420
|
+
partialName = '';
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
dirToRead = path.dirname(expanded);
|
|
424
|
+
partialName = path.basename(expanded).toLowerCase();
|
|
425
|
+
}
|
|
426
|
+
let suggestions = [];
|
|
427
|
+
try {
|
|
428
|
+
const entries = await fs.promises.readdir(dirToRead, { withFileTypes: true });
|
|
429
|
+
suggestions = entries
|
|
430
|
+
.filter((e) => {
|
|
431
|
+
if (!e.isDirectory())
|
|
432
|
+
return false;
|
|
433
|
+
if (e.name.startsWith('.'))
|
|
434
|
+
return false;
|
|
435
|
+
if (!partialName)
|
|
436
|
+
return true;
|
|
437
|
+
return e.name.toLowerCase().startsWith(partialName);
|
|
438
|
+
})
|
|
439
|
+
.map((e) => path.join(dirToRead, e.name))
|
|
440
|
+
.slice(0, 20); // cap results
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
// Directory doesn't exist or permission denied — return empty
|
|
444
|
+
}
|
|
445
|
+
res.json({ suggestions });
|
|
446
|
+
});
|
|
447
|
+
return router;
|
|
448
|
+
}
|
package/dist/server/ws.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { WebSocketServer } from 'ws';
|
|
2
2
|
import * as sessions from './sessions.js';
|
|
3
|
-
import { onSdkEvent, sendMessage as sdkSendMessage, handlePermission as sdkHandlePermission } from './sdk-handler.js';
|
|
4
|
-
const BACKPRESSURE_HIGH = 1024 * 1024; // 1MB
|
|
5
|
-
const BACKPRESSURE_LOW = 512 * 1024; // 512KB
|
|
6
3
|
function parseCookies(cookieHeader) {
|
|
7
4
|
const cookies = {};
|
|
8
5
|
if (!cookieHeader)
|
|
@@ -50,7 +47,7 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
|
|
|
50
47
|
});
|
|
51
48
|
return;
|
|
52
49
|
}
|
|
53
|
-
// PTY
|
|
50
|
+
// PTY channel: /ws/:sessionId
|
|
54
51
|
const match = request.url && request.url.match(/^\/ws\/([a-f0-9]+)$/);
|
|
55
52
|
if (!match) {
|
|
56
53
|
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
@@ -74,16 +71,6 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
|
|
|
74
71
|
const session = sessionMap.get(ws);
|
|
75
72
|
if (!session)
|
|
76
73
|
return;
|
|
77
|
-
if (session.mode === 'sdk') {
|
|
78
|
-
handleSdkConnection(ws, session);
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
// PTY mode — existing behavior
|
|
82
|
-
if (session.mode !== 'pty') {
|
|
83
|
-
ws.close(1008, 'Session mode does not support PTY streaming');
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
const ptySession = session;
|
|
87
74
|
let dataDisposable = null;
|
|
88
75
|
let exitDisposable = null;
|
|
89
76
|
function attachToPty(ptyProcess) {
|
|
@@ -91,7 +78,7 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
|
|
|
91
78
|
dataDisposable?.dispose();
|
|
92
79
|
exitDisposable?.dispose();
|
|
93
80
|
// Replay scrollback
|
|
94
|
-
for (const chunk of
|
|
81
|
+
for (const chunk of session.scrollback) {
|
|
95
82
|
if (ws.readyState === ws.OPEN)
|
|
96
83
|
ws.send(chunk);
|
|
97
84
|
}
|
|
@@ -104,100 +91,52 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
|
|
|
104
91
|
ws.close(1000);
|
|
105
92
|
});
|
|
106
93
|
}
|
|
107
|
-
attachToPty(
|
|
94
|
+
attachToPty(session.pty);
|
|
108
95
|
const ptyReplacedHandler = (newPty) => attachToPty(newPty);
|
|
109
|
-
|
|
96
|
+
session.onPtyReplacedCallbacks.push(ptyReplacedHandler);
|
|
110
97
|
ws.on('message', (msg) => {
|
|
111
98
|
const str = msg.toString();
|
|
112
99
|
try {
|
|
113
100
|
const parsed = JSON.parse(str);
|
|
114
101
|
if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
|
|
115
|
-
sessions.resize(
|
|
102
|
+
sessions.resize(session.id, parsed.cols, parsed.rows);
|
|
116
103
|
return;
|
|
117
104
|
}
|
|
118
105
|
}
|
|
119
106
|
catch (_) { }
|
|
120
|
-
//
|
|
121
|
-
|
|
107
|
+
// Branch rename interception: prepend rename prompt before the user's first message
|
|
108
|
+
if (session.needsBranchRename) {
|
|
109
|
+
if (!session._renameBuffer)
|
|
110
|
+
session._renameBuffer = '';
|
|
111
|
+
const enterIndex = str.indexOf('\r');
|
|
112
|
+
if (enterIndex === -1) {
|
|
113
|
+
// No Enter yet — buffer and pass through so the user sees echo
|
|
114
|
+
session._renameBuffer += str;
|
|
115
|
+
session.pty.write(str);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// Enter detected — inject rename prompt before the user's message
|
|
119
|
+
const buffered = session._renameBuffer;
|
|
120
|
+
const beforeEnter = buffered + str.slice(0, enterIndex);
|
|
121
|
+
const afterEnter = str.slice(enterIndex); // includes the \r
|
|
122
|
+
const renamePrompt = `Before doing anything else, rename the current git branch using \`git branch -m <new-name>\`. Choose a short, descriptive kebab-case branch name based on the task below.${session.branchRenamePrompt ? ' User preferences: ' + session.branchRenamePrompt : ''} Do not ask for confirmation — just rename and proceed.\n\n`;
|
|
123
|
+
const clearLine = '\x15'; // Ctrl+U clears the current input line
|
|
124
|
+
session.pty.write(clearLine + renamePrompt + beforeEnter + afterEnter);
|
|
125
|
+
session.needsBranchRename = false;
|
|
126
|
+
delete session._renameBuffer;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Use session.pty dynamically so writes go to current PTY
|
|
130
|
+
session.pty.write(str);
|
|
122
131
|
});
|
|
123
132
|
ws.on('close', () => {
|
|
124
133
|
dataDisposable?.dispose();
|
|
125
134
|
exitDisposable?.dispose();
|
|
126
|
-
const idx =
|
|
135
|
+
const idx = session.onPtyReplacedCallbacks.indexOf(ptyReplacedHandler);
|
|
127
136
|
if (idx !== -1)
|
|
128
|
-
|
|
137
|
+
session.onPtyReplacedCallbacks.splice(idx, 1);
|
|
129
138
|
});
|
|
130
139
|
});
|
|
131
|
-
function handleSdkConnection(ws, session) {
|
|
132
|
-
// Send session info
|
|
133
|
-
const sessionInfo = JSON.stringify({
|
|
134
|
-
type: 'session_info',
|
|
135
|
-
mode: 'sdk',
|
|
136
|
-
sessionId: session.id,
|
|
137
|
-
});
|
|
138
|
-
if (ws.readyState === ws.OPEN)
|
|
139
|
-
ws.send(sessionInfo);
|
|
140
|
-
// Replay stored events (send as-is — client expects raw SdkEvent shape)
|
|
141
|
-
for (const event of session.events) {
|
|
142
|
-
if (ws.readyState !== ws.OPEN)
|
|
143
|
-
break;
|
|
144
|
-
ws.send(JSON.stringify(event));
|
|
145
|
-
}
|
|
146
|
-
// Subscribe to live events with backpressure
|
|
147
|
-
let paused = false;
|
|
148
|
-
const unsubscribe = onSdkEvent(session.id, (event) => {
|
|
149
|
-
if (ws.readyState !== ws.OPEN)
|
|
150
|
-
return;
|
|
151
|
-
// Backpressure check
|
|
152
|
-
if (ws.bufferedAmount > BACKPRESSURE_HIGH) {
|
|
153
|
-
paused = true;
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
ws.send(JSON.stringify(event));
|
|
157
|
-
});
|
|
158
|
-
// Periodically check if we can resume
|
|
159
|
-
const backpressureInterval = setInterval(() => {
|
|
160
|
-
if (paused && ws.bufferedAmount < BACKPRESSURE_LOW) {
|
|
161
|
-
paused = false;
|
|
162
|
-
}
|
|
163
|
-
}, 100);
|
|
164
|
-
// Handle incoming messages
|
|
165
|
-
ws.on('message', (msg) => {
|
|
166
|
-
const str = msg.toString();
|
|
167
|
-
try {
|
|
168
|
-
const parsed = JSON.parse(str);
|
|
169
|
-
if (parsed.type === 'message' && typeof parsed.text === 'string') {
|
|
170
|
-
if (parsed.text.length > 100_000)
|
|
171
|
-
return;
|
|
172
|
-
sdkSendMessage(session.id, parsed.text);
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
if (parsed.type === 'permission' && typeof parsed.requestId === 'string' && typeof parsed.approved === 'boolean') {
|
|
176
|
-
sdkHandlePermission(session.id, parsed.requestId, parsed.approved);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
if (parsed.type === 'resize' && typeof parsed.cols === 'number' && typeof parsed.rows === 'number') {
|
|
180
|
-
// TODO: wire up companion shell — currently open_companion message is unhandled server-side
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
if (parsed.type === 'open_companion') {
|
|
184
|
-
// TODO: spawn companion PTY in session CWD and relay via terminal_data/terminal_exit frames
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
catch (_) {
|
|
189
|
-
// Not JSON — ignore for SDK sessions
|
|
190
|
-
}
|
|
191
|
-
});
|
|
192
|
-
ws.on('close', () => {
|
|
193
|
-
unsubscribe();
|
|
194
|
-
clearInterval(backpressureInterval);
|
|
195
|
-
});
|
|
196
|
-
ws.on('error', () => {
|
|
197
|
-
unsubscribe();
|
|
198
|
-
clearInterval(backpressureInterval);
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
140
|
sessions.onIdleChange((sessionId, idle) => {
|
|
202
141
|
broadcastEvent('session-idle-changed', { sessionId, idle });
|
|
203
142
|
});
|