jumper-app 0.1.0 → 0.1.3
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/cli.js +0 -0
- package/dist/index.js +268 -30
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,9 @@ import { loadRelaySession, nowIso, loadState, saveRelaySession, saveState } from
|
|
|
13
13
|
const PORT = Number(process.env.PORT ?? "8787");
|
|
14
14
|
const HOST = process.env.HOST ?? "0.0.0.0";
|
|
15
15
|
const RELAY_URL = process.env.RELAY_URL;
|
|
16
|
-
const PROJECTS_ROOT = process.env.PROJECTS_ROOT ?? path.join(os.homedir(), "dev", "
|
|
16
|
+
const PROJECTS_ROOT = process.env.PROJECTS_ROOT ?? path.join(os.homedir(), "dev", "jumper-projects");
|
|
17
|
+
const CLAUDE_HISTORY_PATH = path.join(os.homedir(), ".claude", "history.jsonl");
|
|
18
|
+
const CLAUDE_PROJECTS_PATH = path.join(os.homedir(), ".claude", "projects");
|
|
17
19
|
const KEYBOARD_TIMEOUT_MS = 20_000;
|
|
18
20
|
const MAX_SELECTED_TEXT_CHARS = 2_000;
|
|
19
21
|
const MAX_CONTEXT_SIDE_CHARS = 1_000;
|
|
@@ -68,6 +70,213 @@ async function normalizeStateProjectPaths(state) {
|
|
|
68
70
|
await saveState(next);
|
|
69
71
|
return next;
|
|
70
72
|
}
|
|
73
|
+
function extractJsonStringField(line, field) {
|
|
74
|
+
const match = line.match(new RegExp(`"${field}"\\s*:\\s*"([^"]+)"`));
|
|
75
|
+
const value = match?.[1];
|
|
76
|
+
if (!value)
|
|
77
|
+
return null;
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
function extractJsonNumberField(line, field) {
|
|
81
|
+
const match = line.match(new RegExp(`"${field}"\\s*:\\s*(\\d+)`));
|
|
82
|
+
const raw = match?.[1];
|
|
83
|
+
if (!raw)
|
|
84
|
+
return null;
|
|
85
|
+
return Number(raw);
|
|
86
|
+
}
|
|
87
|
+
function parentDirectory(input) {
|
|
88
|
+
const parent = path.dirname(input);
|
|
89
|
+
if (parent === input)
|
|
90
|
+
return null;
|
|
91
|
+
return parent;
|
|
92
|
+
}
|
|
93
|
+
async function recentProjectsFromClaudeHistory(limit = 300) {
|
|
94
|
+
if (!existsSync(CLAUDE_HISTORY_PATH))
|
|
95
|
+
return [];
|
|
96
|
+
const raw = await fs.readFile(CLAUDE_HISTORY_PATH, "utf8");
|
|
97
|
+
const lines = raw.split("\n");
|
|
98
|
+
const projects = [];
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
101
|
+
if (projects.length >= limit)
|
|
102
|
+
break;
|
|
103
|
+
const line = lines[i]?.trim();
|
|
104
|
+
if (!line)
|
|
105
|
+
continue;
|
|
106
|
+
const project = extractJsonStringField(line, "project");
|
|
107
|
+
if (!project)
|
|
108
|
+
continue;
|
|
109
|
+
const normalized = normalizeProjectPath(project);
|
|
110
|
+
if (!existsSync(normalized))
|
|
111
|
+
continue;
|
|
112
|
+
if (seen.has(normalized))
|
|
113
|
+
continue;
|
|
114
|
+
seen.add(normalized);
|
|
115
|
+
projects.push(normalized);
|
|
116
|
+
}
|
|
117
|
+
return projects;
|
|
118
|
+
}
|
|
119
|
+
function rankWorkspaceRoots(projectPaths) {
|
|
120
|
+
const counts = new Map();
|
|
121
|
+
projectPaths.forEach((projectPath, rank) => {
|
|
122
|
+
const parent = parentDirectory(projectPath);
|
|
123
|
+
if (!parent)
|
|
124
|
+
return;
|
|
125
|
+
const existing = counts.get(parent);
|
|
126
|
+
if (!existing) {
|
|
127
|
+
counts.set(parent, { count: 1, firstSeenRank: rank });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
counts.set(parent, { count: existing.count + 1, firstSeenRank: existing.firstSeenRank });
|
|
131
|
+
});
|
|
132
|
+
return Array.from(counts.entries())
|
|
133
|
+
.map(([candidatePath, candidate]) => ({ path: candidatePath, ...candidate }))
|
|
134
|
+
.sort((a, b) => {
|
|
135
|
+
if (b.count !== a.count)
|
|
136
|
+
return b.count - a.count;
|
|
137
|
+
return a.firstSeenRank - b.firstSeenRank;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async function mostRecentProjectFromClaudeSessions() {
|
|
141
|
+
if (!existsSync(CLAUDE_PROJECTS_PATH))
|
|
142
|
+
return null;
|
|
143
|
+
const projectDirs = await fs.readdir(CLAUDE_PROJECTS_PATH, { withFileTypes: true });
|
|
144
|
+
let latestSessionFile = null;
|
|
145
|
+
let latestMtimeMs = -1;
|
|
146
|
+
for (const projectDir of projectDirs) {
|
|
147
|
+
if (!projectDir.isDirectory())
|
|
148
|
+
continue;
|
|
149
|
+
const projectDirPath = path.join(CLAUDE_PROJECTS_PATH, projectDir.name);
|
|
150
|
+
const entries = await fs.readdir(projectDirPath, { withFileTypes: true });
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
if (!entry.isFile())
|
|
153
|
+
continue;
|
|
154
|
+
if (!entry.name.endsWith(".jsonl"))
|
|
155
|
+
continue;
|
|
156
|
+
const filePath = path.join(projectDirPath, entry.name);
|
|
157
|
+
const stats = await fs.stat(filePath);
|
|
158
|
+
if (stats.mtimeMs <= latestMtimeMs)
|
|
159
|
+
continue;
|
|
160
|
+
latestMtimeMs = stats.mtimeMs;
|
|
161
|
+
latestSessionFile = filePath;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!latestSessionFile)
|
|
165
|
+
return null;
|
|
166
|
+
const raw = await fs.readFile(latestSessionFile, "utf8");
|
|
167
|
+
const lines = raw.split("\n");
|
|
168
|
+
for (const line of lines) {
|
|
169
|
+
const trimmed = line.trim();
|
|
170
|
+
if (!trimmed)
|
|
171
|
+
continue;
|
|
172
|
+
const cwd = extractJsonStringField(trimmed, "cwd");
|
|
173
|
+
if (!cwd)
|
|
174
|
+
continue;
|
|
175
|
+
const normalized = normalizeProjectPath(cwd);
|
|
176
|
+
if (!existsSync(normalized))
|
|
177
|
+
continue;
|
|
178
|
+
return normalized;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
async function inferDefaultFolderBrowsePath() {
|
|
183
|
+
const recentHistoryProjects = await recentProjectsFromClaudeHistory();
|
|
184
|
+
const rankedRoots = rankWorkspaceRoots(recentHistoryProjects);
|
|
185
|
+
const likelyHistoryRoot = rankedRoots[0]?.path ?? null;
|
|
186
|
+
if (likelyHistoryRoot && existsSync(likelyHistoryRoot))
|
|
187
|
+
return likelyHistoryRoot;
|
|
188
|
+
const fromHistory = recentHistoryProjects[0];
|
|
189
|
+
if (fromHistory) {
|
|
190
|
+
const parent = parentDirectory(fromHistory);
|
|
191
|
+
if (parent && existsSync(parent))
|
|
192
|
+
return parent;
|
|
193
|
+
return fromHistory;
|
|
194
|
+
}
|
|
195
|
+
const fromSessions = await mostRecentProjectFromClaudeSessions();
|
|
196
|
+
if (fromSessions) {
|
|
197
|
+
const parent = parentDirectory(fromSessions);
|
|
198
|
+
if (parent && existsSync(parent))
|
|
199
|
+
return parent;
|
|
200
|
+
return fromSessions;
|
|
201
|
+
}
|
|
202
|
+
return os.homedir();
|
|
203
|
+
}
|
|
204
|
+
async function inferSuggestedFolderRoots(max = 3) {
|
|
205
|
+
const recentHistoryProjects = await recentProjectsFromClaudeHistory();
|
|
206
|
+
const rankedRoots = rankWorkspaceRoots(recentHistoryProjects).map((entry) => entry.path);
|
|
207
|
+
const suggestions = [...rankedRoots];
|
|
208
|
+
const mostRecentProject = recentHistoryProjects[0];
|
|
209
|
+
if (mostRecentProject) {
|
|
210
|
+
suggestions.push(mostRecentProject);
|
|
211
|
+
}
|
|
212
|
+
const fromSessions = await mostRecentProjectFromClaudeSessions();
|
|
213
|
+
if (fromSessions) {
|
|
214
|
+
const parent = parentDirectory(fromSessions);
|
|
215
|
+
if (parent)
|
|
216
|
+
suggestions.push(parent);
|
|
217
|
+
suggestions.push(fromSessions);
|
|
218
|
+
}
|
|
219
|
+
suggestions.push(os.homedir());
|
|
220
|
+
const unique = [];
|
|
221
|
+
for (const candidate of suggestions) {
|
|
222
|
+
if (!existsSync(candidate))
|
|
223
|
+
continue;
|
|
224
|
+
if (unique.includes(candidate))
|
|
225
|
+
continue;
|
|
226
|
+
unique.push(candidate);
|
|
227
|
+
if (unique.length >= max)
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
return unique;
|
|
231
|
+
}
|
|
232
|
+
async function inferResumeFolders(max = 5) {
|
|
233
|
+
if (!existsSync(CLAUDE_HISTORY_PATH))
|
|
234
|
+
return [];
|
|
235
|
+
const raw = await fs.readFile(CLAUDE_HISTORY_PATH, "utf8");
|
|
236
|
+
const lines = raw.split("\n");
|
|
237
|
+
const statsByPath = new Map();
|
|
238
|
+
let scanned = 0;
|
|
239
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
240
|
+
if (scanned >= 4_000)
|
|
241
|
+
break;
|
|
242
|
+
const line = lines[i]?.trim();
|
|
243
|
+
if (!line)
|
|
244
|
+
continue;
|
|
245
|
+
scanned += 1;
|
|
246
|
+
const project = extractJsonStringField(line, "project");
|
|
247
|
+
const sessionId = extractJsonStringField(line, "sessionId");
|
|
248
|
+
if (!project || !sessionId)
|
|
249
|
+
continue;
|
|
250
|
+
const normalizedPath = normalizeProjectPath(project);
|
|
251
|
+
if (!existsSync(normalizedPath))
|
|
252
|
+
continue;
|
|
253
|
+
const timestamp = extractJsonNumberField(line, "timestamp") ?? 0;
|
|
254
|
+
const existing = statsByPath.get(normalizedPath);
|
|
255
|
+
if (!existing) {
|
|
256
|
+
statsByPath.set(normalizedPath, {
|
|
257
|
+
sessions: new Set([sessionId]),
|
|
258
|
+
lastActive: timestamp,
|
|
259
|
+
});
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
existing.sessions.add(sessionId);
|
|
263
|
+
if (timestamp > existing.lastActive)
|
|
264
|
+
existing.lastActive = timestamp;
|
|
265
|
+
}
|
|
266
|
+
return Array.from(statsByPath.entries())
|
|
267
|
+
.map(([path, value]) => ({
|
|
268
|
+
path,
|
|
269
|
+
conversationCount: value.sessions.size,
|
|
270
|
+
lastActive: value.lastActive,
|
|
271
|
+
}))
|
|
272
|
+
.sort((a, b) => {
|
|
273
|
+
if (b.lastActive !== a.lastActive)
|
|
274
|
+
return b.lastActive - a.lastActive;
|
|
275
|
+
return b.conversationCount - a.conversationCount;
|
|
276
|
+
})
|
|
277
|
+
.slice(0, max)
|
|
278
|
+
.map(({ path, conversationCount }) => ({ path, conversationCount }));
|
|
279
|
+
}
|
|
71
280
|
function claudeProjectDir(cwd) {
|
|
72
281
|
return path.join(os.homedir(), ".claude", "projects", cwd.split(path.sep).join("-"));
|
|
73
282
|
}
|
|
@@ -505,7 +714,7 @@ function wsUrlForHost(hostWithPort, secure) {
|
|
|
505
714
|
return `${secure ? "wss" : "ws"}://${hostWithPort}/ws`;
|
|
506
715
|
}
|
|
507
716
|
function connectLinkForServer(serverUrl) {
|
|
508
|
-
return `
|
|
717
|
+
return `jumper://connect?server=${encodeURIComponent(serverUrl)}`;
|
|
509
718
|
}
|
|
510
719
|
function connectPageUrlForServer(hostWithPort, secure) {
|
|
511
720
|
return `${secure ? "https" : "http"}://${hostWithPort}/connect`;
|
|
@@ -524,14 +733,16 @@ async function main() {
|
|
|
524
733
|
const suggestedWs = wsUrlForHost(suggestedHost, false);
|
|
525
734
|
const suggestedConnectPage = connectPageUrlForServer(suggestedHost, false);
|
|
526
735
|
const suggestedDeepLink = connectLinkForServer(suggestedWs);
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
console.log(`[cc-bridge] connect link: ${suggestedDeepLink}`);
|
|
736
|
+
const startupQr = await QRCode.toString(suggestedDeepLink, {
|
|
737
|
+
type: "terminal",
|
|
738
|
+
small: true,
|
|
739
|
+
});
|
|
532
740
|
const active = new Map();
|
|
533
741
|
let state = await loadState();
|
|
534
742
|
state = await normalizeStateProjectPaths(state);
|
|
743
|
+
let defaultFolderBrowsePath = null;
|
|
744
|
+
let suggestedFolderRoots = null;
|
|
745
|
+
let resumeFolders = null;
|
|
535
746
|
const handleClientMessage = async (msg, reply) => {
|
|
536
747
|
if (msg.type === "projects.list") {
|
|
537
748
|
reply({ type: "projects.list.result", projects: state.projects });
|
|
@@ -601,6 +812,44 @@ async function main() {
|
|
|
601
812
|
reply({ type: "upload-image.result", attachment });
|
|
602
813
|
return;
|
|
603
814
|
}
|
|
815
|
+
if (msg.type === "folders.list") {
|
|
816
|
+
if (!suggestedFolderRoots) {
|
|
817
|
+
suggestedFolderRoots = await inferSuggestedFolderRoots(3);
|
|
818
|
+
}
|
|
819
|
+
if (!resumeFolders) {
|
|
820
|
+
resumeFolders = await inferResumeFolders(5);
|
|
821
|
+
}
|
|
822
|
+
if (!defaultFolderBrowsePath) {
|
|
823
|
+
defaultFolderBrowsePath = suggestedFolderRoots[0] ?? (await inferDefaultFolderBrowsePath());
|
|
824
|
+
}
|
|
825
|
+
let requestedPath;
|
|
826
|
+
if (typeof msg.path === "string" && msg.path.trim().length > 0) {
|
|
827
|
+
requestedPath = msg.path;
|
|
828
|
+
}
|
|
829
|
+
else {
|
|
830
|
+
requestedPath = defaultFolderBrowsePath;
|
|
831
|
+
}
|
|
832
|
+
const folderPath = normalizeProjectPath(requestedPath);
|
|
833
|
+
const entries = await fs.readdir(folderPath, { withFileTypes: true });
|
|
834
|
+
const directories = entries
|
|
835
|
+
.filter((entry) => entry.isDirectory())
|
|
836
|
+
.map((entry) => ({
|
|
837
|
+
name: entry.name,
|
|
838
|
+
path: path.join(folderPath, entry.name),
|
|
839
|
+
}))
|
|
840
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
841
|
+
const parentPath = path.dirname(folderPath);
|
|
842
|
+
reply({
|
|
843
|
+
type: "folders.list.result",
|
|
844
|
+
requestId: msg.requestId,
|
|
845
|
+
path: folderPath,
|
|
846
|
+
parentPath: parentPath === folderPath ? null : parentPath,
|
|
847
|
+
directories,
|
|
848
|
+
suggestedRoots: suggestedFolderRoots,
|
|
849
|
+
resumeFolders,
|
|
850
|
+
});
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
604
853
|
if (msg.type === "chats.send") {
|
|
605
854
|
const chat = state.chats.find((c) => c.id === msg.chatId);
|
|
606
855
|
if (!chat)
|
|
@@ -684,7 +933,7 @@ async function main() {
|
|
|
684
933
|
<head>
|
|
685
934
|
<meta charset="utf-8" />
|
|
686
935
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
687
|
-
<title>
|
|
936
|
+
<title>Connect to Jumper</title>
|
|
688
937
|
<style>
|
|
689
938
|
:root {
|
|
690
939
|
color-scheme: light;
|
|
@@ -793,8 +1042,8 @@ async function main() {
|
|
|
793
1042
|
</head>
|
|
794
1043
|
<body>
|
|
795
1044
|
<div class="card">
|
|
796
|
-
<h1>Connect
|
|
797
|
-
<p>
|
|
1045
|
+
<h1>Connect to Jumper</h1>
|
|
1046
|
+
<p>Start jumping with one scan. The app saves your server URL and reconnects automatically.</p>
|
|
798
1047
|
|
|
799
1048
|
<div class="layout">
|
|
800
1049
|
<img class="qr" src="${qrCodeImage}" alt="QR code to connect mobile app" />
|
|
@@ -805,7 +1054,7 @@ async function main() {
|
|
|
805
1054
|
</div>
|
|
806
1055
|
<div class="step">
|
|
807
1056
|
<div class="num">2</div>
|
|
808
|
-
<p>Tap the banner to open the
|
|
1057
|
+
<p>Tap the banner to open the Jumper app.</p>
|
|
809
1058
|
</div>
|
|
810
1059
|
<div class="step">
|
|
811
1060
|
<div class="num">3</div>
|
|
@@ -882,7 +1131,7 @@ async function main() {
|
|
|
882
1131
|
return;
|
|
883
1132
|
}
|
|
884
1133
|
res.writeHead(200, { "content-type": "text/plain" });
|
|
885
|
-
res.end("
|
|
1134
|
+
res.end("jumper\n");
|
|
886
1135
|
});
|
|
887
1136
|
const wss = new WebSocketServer({ noServer: true });
|
|
888
1137
|
httpServer.on("upgrade", (req, socket, head) => {
|
|
@@ -904,38 +1153,27 @@ async function main() {
|
|
|
904
1153
|
relayUrl: RELAY_URL,
|
|
905
1154
|
session: relaySession,
|
|
906
1155
|
onPairingCode: (message) => {
|
|
907
|
-
console.log(`
|
|
908
|
-
console.log("[cc-bridge] Enter this code in the mobile app to connect.");
|
|
1156
|
+
console.log(`Pairing code: ${message.code}`);
|
|
909
1157
|
},
|
|
910
1158
|
onPaired: async (session) => {
|
|
911
1159
|
await saveRelaySession(session);
|
|
912
|
-
console.log(`[cc-bridge] relay paired: session ${session.sessionId}`);
|
|
913
1160
|
},
|
|
914
1161
|
onPayload: async (payload) => {
|
|
915
1162
|
const msg = parseMessagePayload(payload);
|
|
916
1163
|
await handleClientMessage(msg, (message) => relayClient.send(JSON.stringify(message)));
|
|
917
1164
|
},
|
|
918
1165
|
onControlMessage: (message) => {
|
|
919
|
-
if (message.type === "relay.peer_connected") {
|
|
920
|
-
console.log(`[cc-bridge] relay peer connected: ${message.peer}`);
|
|
921
|
-
return;
|
|
922
|
-
}
|
|
923
|
-
if (message.type === "relay.peer_disconnected") {
|
|
924
|
-
console.log(`[cc-bridge] relay peer disconnected: ${message.peer}`);
|
|
925
|
-
return;
|
|
926
|
-
}
|
|
927
|
-
if (message.type === "relay.reconnected") {
|
|
928
|
-
console.log(`[cc-bridge] relay reconnected: ${message.role}`);
|
|
929
|
-
return;
|
|
930
|
-
}
|
|
931
1166
|
if (message.type === "relay.error") {
|
|
932
|
-
console.
|
|
1167
|
+
console.error(`Relay error: ${message.message}`);
|
|
933
1168
|
}
|
|
934
1169
|
},
|
|
935
1170
|
});
|
|
936
1171
|
relayClient.connect();
|
|
937
|
-
console.log(`[cc-bridge] relay: ${RELAY_URL}`);
|
|
938
1172
|
}
|
|
939
|
-
|
|
1173
|
+
await new Promise((resolve) => {
|
|
1174
|
+
httpServer.listen(PORT, HOST, () => resolve());
|
|
1175
|
+
});
|
|
1176
|
+
console.log("Bridge started.\n");
|
|
1177
|
+
console.log(startupQr);
|
|
940
1178
|
}
|
|
941
1179
|
void main();
|