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 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", "cc-bridge-projects");
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 `mobile://connect?server=${encodeURIComponent(serverUrl)}`;
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
- console.log(`[cc-bridge] projects root: ${PROJECTS_ROOT}`);
528
- console.log(`[cc-bridge] host: ${HOST}`);
529
- console.log(`[cc-bridge] ws: ws://${HOST}:${PORT}/ws`);
530
- console.log(`[cc-bridge] connect page: ${suggestedConnectPage}`);
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>cc-bridge Connect</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 iPhone to cc-bridge</h1>
797
- <p>Scan once. The app saves your server URL and connects automatically.</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 cc-bridge app.</p>
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("cc-bridge\n");
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(`[cc-bridge] relay code: ${message.code}`);
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.log(`[cc-bridge] relay error: ${message.message}`);
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
- httpServer.listen(PORT, HOST);
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jumper-app",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {