jumper-app 0.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/dist/cli.js +30 -0
- package/dist/index.js +941 -0
- package/dist/relay-client.js +128 -0
- package/dist/state.js +74 -0
- package/dist/types.js +1 -0
- package/package.json +30 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const DEFAULT_RELAY_URL = "wss://relay.jumper.sh";
|
|
3
|
+
function printHelp() {
|
|
4
|
+
process.stdout.write([
|
|
5
|
+
"jumper-app",
|
|
6
|
+
"",
|
|
7
|
+
"Starts the Jumper bridge server for the iOS app.",
|
|
8
|
+
"",
|
|
9
|
+
"Environment variables:",
|
|
10
|
+
" PORT HTTP/WebSocket port (default: 8787)",
|
|
11
|
+
" HOST Bind host (default: 0.0.0.0)",
|
|
12
|
+
" PUBLIC_HOST Public host:port shown in connect instructions",
|
|
13
|
+
` RELAY_URL Relay websocket URL (default: ${DEFAULT_RELAY_URL})`,
|
|
14
|
+
"",
|
|
15
|
+
"Examples:",
|
|
16
|
+
" npx jumper-app",
|
|
17
|
+
" PORT=9000 npx jumper-app",
|
|
18
|
+
" RELAY_URL= npx jumper-app",
|
|
19
|
+
"",
|
|
20
|
+
].join("\n"));
|
|
21
|
+
}
|
|
22
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
23
|
+
printHelp();
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
if (!process.env.RELAY_URL) {
|
|
27
|
+
process.env.RELAY_URL = DEFAULT_RELAY_URL;
|
|
28
|
+
}
|
|
29
|
+
await import("./index.js");
|
|
30
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import http from "node:http";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import readline from "node:readline";
|
|
9
|
+
import QRCode from "qrcode";
|
|
10
|
+
import { WebSocketServer } from "ws";
|
|
11
|
+
import { RelayClient } from "./relay-client.js";
|
|
12
|
+
import { loadRelaySession, nowIso, loadState, saveRelaySession, saveState } from "./state.js";
|
|
13
|
+
const PORT = Number(process.env.PORT ?? "8787");
|
|
14
|
+
const HOST = process.env.HOST ?? "0.0.0.0";
|
|
15
|
+
const RELAY_URL = process.env.RELAY_URL;
|
|
16
|
+
const PROJECTS_ROOT = process.env.PROJECTS_ROOT ?? path.join(os.homedir(), "dev", "cc-bridge-projects");
|
|
17
|
+
const KEYBOARD_TIMEOUT_MS = 20_000;
|
|
18
|
+
const MAX_SELECTED_TEXT_CHARS = 2_000;
|
|
19
|
+
const MAX_CONTEXT_SIDE_CHARS = 1_000;
|
|
20
|
+
const MAX_CONVERSATION_ENTRY_CHARS = 600;
|
|
21
|
+
const MAX_CONVERSATION_ENTRIES = 12;
|
|
22
|
+
function slugify(name) {
|
|
23
|
+
const s = name
|
|
24
|
+
.trim()
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9-_]+/g, "-")
|
|
27
|
+
.replace(/-+/g, "-")
|
|
28
|
+
.replace(/^-|-$/g, "");
|
|
29
|
+
if (!s)
|
|
30
|
+
throw new Error("Invalid project name");
|
|
31
|
+
return s;
|
|
32
|
+
}
|
|
33
|
+
function isObject(v) {
|
|
34
|
+
return typeof v === "object" && v !== null;
|
|
35
|
+
}
|
|
36
|
+
function expandHomePath(input) {
|
|
37
|
+
const trimmed = input.trim();
|
|
38
|
+
if (trimmed === "~")
|
|
39
|
+
return os.homedir();
|
|
40
|
+
if (trimmed.startsWith("~/"))
|
|
41
|
+
return path.join(os.homedir(), trimmed.slice(2));
|
|
42
|
+
if (trimmed.startsWith("~\\"))
|
|
43
|
+
return path.join(os.homedir(), trimmed.slice(2));
|
|
44
|
+
return trimmed;
|
|
45
|
+
}
|
|
46
|
+
function normalizeProjectPath(input) {
|
|
47
|
+
const trimmed = input.trim();
|
|
48
|
+
if (trimmed.length === 0)
|
|
49
|
+
throw new Error("Project path cannot be empty");
|
|
50
|
+
const expanded = expandHomePath(trimmed);
|
|
51
|
+
if (path.isAbsolute(expanded)) {
|
|
52
|
+
return path.normalize(expanded);
|
|
53
|
+
}
|
|
54
|
+
// Relative paths from mobile are ambiguous; anchor them to the user's home directory.
|
|
55
|
+
return path.resolve(os.homedir(), expanded);
|
|
56
|
+
}
|
|
57
|
+
async function normalizeStateProjectPaths(state) {
|
|
58
|
+
const normalizedProjects = state.projects.map((project) => {
|
|
59
|
+
const normalizedPath = normalizeProjectPath(project.path);
|
|
60
|
+
if (normalizedPath === project.path)
|
|
61
|
+
return project;
|
|
62
|
+
return { ...project, path: normalizedPath };
|
|
63
|
+
});
|
|
64
|
+
const changed = normalizedProjects.some((project, index) => project !== state.projects[index]);
|
|
65
|
+
if (!changed)
|
|
66
|
+
return state;
|
|
67
|
+
const next = { ...state, projects: normalizedProjects };
|
|
68
|
+
await saveState(next);
|
|
69
|
+
return next;
|
|
70
|
+
}
|
|
71
|
+
function claudeProjectDir(cwd) {
|
|
72
|
+
return path.join(os.homedir(), ".claude", "projects", cwd.split(path.sep).join("-"));
|
|
73
|
+
}
|
|
74
|
+
function claudeHistoryCandidates(projectPath) {
|
|
75
|
+
const trimmed = projectPath.trim();
|
|
76
|
+
const expanded = expandHomePath(trimmed);
|
|
77
|
+
const resolvedRaw = path.resolve(trimmed);
|
|
78
|
+
const resolvedExpanded = path.resolve(expanded);
|
|
79
|
+
return Array.from(new Set([trimmed, expanded, resolvedRaw, resolvedExpanded].filter((v) => v.length > 0)));
|
|
80
|
+
}
|
|
81
|
+
async function findHistoryFileBySessionId(sessionId) {
|
|
82
|
+
const projectsDir = path.join(os.homedir(), ".claude", "projects");
|
|
83
|
+
if (!existsSync(projectsDir))
|
|
84
|
+
return undefined;
|
|
85
|
+
const entries = await fs.readdir(projectsDir, { withFileTypes: true });
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (!entry.isDirectory())
|
|
88
|
+
continue;
|
|
89
|
+
const candidate = path.join(projectsDir, entry.name, `${sessionId}.jsonl`);
|
|
90
|
+
if (existsSync(candidate))
|
|
91
|
+
return candidate;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
function extractAssistantText(entry) {
|
|
96
|
+
if (!isObject(entry) || entry.type !== "assistant")
|
|
97
|
+
return null;
|
|
98
|
+
if (!isObject(entry.message))
|
|
99
|
+
return null;
|
|
100
|
+
const content = entry.message.content;
|
|
101
|
+
if (!Array.isArray(content))
|
|
102
|
+
return null;
|
|
103
|
+
const parts = [];
|
|
104
|
+
for (const block of content) {
|
|
105
|
+
if (!isObject(block))
|
|
106
|
+
continue;
|
|
107
|
+
if (block.type !== "text")
|
|
108
|
+
continue;
|
|
109
|
+
if (typeof block.text !== "string")
|
|
110
|
+
continue;
|
|
111
|
+
parts.push(block.text);
|
|
112
|
+
}
|
|
113
|
+
if (parts.length === 0)
|
|
114
|
+
return null;
|
|
115
|
+
return parts.join("");
|
|
116
|
+
}
|
|
117
|
+
function extractUserText(entry) {
|
|
118
|
+
if (!isObject(entry) || entry.type !== "user")
|
|
119
|
+
return null;
|
|
120
|
+
if (!isObject(entry.message))
|
|
121
|
+
return null;
|
|
122
|
+
if (entry.message.role !== "user")
|
|
123
|
+
return null;
|
|
124
|
+
if (typeof entry.message.content !== "string")
|
|
125
|
+
return null;
|
|
126
|
+
return entry.message.content;
|
|
127
|
+
}
|
|
128
|
+
async function loadClaudeHistory(projectPath, sessionId) {
|
|
129
|
+
let filePath = claudeHistoryCandidates(projectPath)
|
|
130
|
+
.map((cwd) => path.join(claudeProjectDir(cwd), `${sessionId}.jsonl`))
|
|
131
|
+
.find((candidate) => existsSync(candidate));
|
|
132
|
+
if (!filePath) {
|
|
133
|
+
filePath = await findHistoryFileBySessionId(sessionId);
|
|
134
|
+
}
|
|
135
|
+
if (!filePath)
|
|
136
|
+
return { messages: [], events: [] };
|
|
137
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
138
|
+
const lines = raw.split("\n").filter((line) => line.trim().length > 0);
|
|
139
|
+
const messages = [];
|
|
140
|
+
const events = [];
|
|
141
|
+
lines.forEach((line, index) => {
|
|
142
|
+
const entry = JSON.parse(line);
|
|
143
|
+
if (!isObject(entry))
|
|
144
|
+
return;
|
|
145
|
+
if (entry.type === "assistant" ||
|
|
146
|
+
entry.type === "user" ||
|
|
147
|
+
entry.type === "progress" ||
|
|
148
|
+
entry.type === "result") {
|
|
149
|
+
events.push(entry);
|
|
150
|
+
}
|
|
151
|
+
const userText = extractUserText(entry);
|
|
152
|
+
if (typeof userText === "string") {
|
|
153
|
+
const id = typeof entry.uuid === "string" ? entry.uuid : `history-user:${index}`;
|
|
154
|
+
messages.push({ id, role: "user", text: userText });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const assistantText = extractAssistantText(entry);
|
|
158
|
+
if (typeof assistantText === "string") {
|
|
159
|
+
const id = typeof entry.uuid === "string" ? entry.uuid : `history-assistant:${index}`;
|
|
160
|
+
messages.push({ id, role: "assistant", text: assistantText });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
return { messages, events };
|
|
164
|
+
}
|
|
165
|
+
function send(ws, msg) {
|
|
166
|
+
ws.send(JSON.stringify(msg));
|
|
167
|
+
}
|
|
168
|
+
function parseMessagePayload(payload) {
|
|
169
|
+
if (!payload || typeof payload !== "object")
|
|
170
|
+
throw new Error("Message must be an object");
|
|
171
|
+
const t = payload.type;
|
|
172
|
+
if (typeof t !== "string")
|
|
173
|
+
throw new Error("Message missing type");
|
|
174
|
+
return payload;
|
|
175
|
+
}
|
|
176
|
+
function parseMessage(data) {
|
|
177
|
+
const text = typeof data === "string" ? data : data.toString("utf8");
|
|
178
|
+
const parsed = JSON.parse(text);
|
|
179
|
+
return parseMessagePayload(parsed);
|
|
180
|
+
}
|
|
181
|
+
function jsonResponse(res, statusCode, value) {
|
|
182
|
+
res.writeHead(statusCode, { "content-type": "application/json" });
|
|
183
|
+
res.end(JSON.stringify(value));
|
|
184
|
+
}
|
|
185
|
+
async function readJsonBody(req) {
|
|
186
|
+
const chunks = [];
|
|
187
|
+
for await (const chunk of req) {
|
|
188
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
189
|
+
}
|
|
190
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
191
|
+
return JSON.parse(text);
|
|
192
|
+
}
|
|
193
|
+
function parseUploadImageBody(payload) {
|
|
194
|
+
if (!isObject(payload))
|
|
195
|
+
return null;
|
|
196
|
+
if (typeof payload.chatId !== "string" ||
|
|
197
|
+
typeof payload.fileName !== "string" ||
|
|
198
|
+
typeof payload.mimeType !== "string" ||
|
|
199
|
+
typeof payload.base64 !== "string") {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
chatId: payload.chatId,
|
|
204
|
+
fileName: payload.fileName,
|
|
205
|
+
mimeType: payload.mimeType,
|
|
206
|
+
base64: payload.base64,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function clampText(value, maxLength) {
|
|
210
|
+
if (value.length <= maxLength)
|
|
211
|
+
return value;
|
|
212
|
+
return value.slice(0, maxLength);
|
|
213
|
+
}
|
|
214
|
+
function optionalString(value) {
|
|
215
|
+
if (typeof value !== "string")
|
|
216
|
+
return undefined;
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
function optionalStringArray(value) {
|
|
220
|
+
if (!Array.isArray(value))
|
|
221
|
+
return undefined;
|
|
222
|
+
const strings = value.filter((item) => typeof item === "string");
|
|
223
|
+
if (strings.length === 0)
|
|
224
|
+
return undefined;
|
|
225
|
+
return strings;
|
|
226
|
+
}
|
|
227
|
+
function optionalStringRecord(value) {
|
|
228
|
+
if (!isObject(value))
|
|
229
|
+
return undefined;
|
|
230
|
+
const entries = Object.entries(value).filter((entry) => {
|
|
231
|
+
const [, v] = entry;
|
|
232
|
+
return typeof v === "string";
|
|
233
|
+
});
|
|
234
|
+
if (entries.length === 0)
|
|
235
|
+
return undefined;
|
|
236
|
+
return Object.fromEntries(entries);
|
|
237
|
+
}
|
|
238
|
+
function sanitizeConversationEntry(value) {
|
|
239
|
+
if (!isObject(value))
|
|
240
|
+
return null;
|
|
241
|
+
if (typeof value.text !== "string")
|
|
242
|
+
return null;
|
|
243
|
+
const entry = {
|
|
244
|
+
text: clampText(value.text, MAX_CONVERSATION_ENTRY_CHARS),
|
|
245
|
+
};
|
|
246
|
+
const senderIdentifier = optionalString(value.senderIdentifier);
|
|
247
|
+
if (senderIdentifier)
|
|
248
|
+
entry.senderIdentifier = senderIdentifier;
|
|
249
|
+
const sentDate = optionalString(value.sentDate);
|
|
250
|
+
if (sentDate)
|
|
251
|
+
entry.sentDate = sentDate;
|
|
252
|
+
const entryIdentifier = optionalString(value.entryIdentifier);
|
|
253
|
+
if (entryIdentifier)
|
|
254
|
+
entry.entryIdentifier = entryIdentifier;
|
|
255
|
+
if (typeof value.replyThreadIdentifier === "string" || value.replyThreadIdentifier === null) {
|
|
256
|
+
entry.replyThreadIdentifier = value.replyThreadIdentifier;
|
|
257
|
+
}
|
|
258
|
+
const primaryRecipientIdentifiers = optionalStringArray(value.primaryRecipientIdentifiers);
|
|
259
|
+
if (primaryRecipientIdentifiers)
|
|
260
|
+
entry.primaryRecipientIdentifiers = primaryRecipientIdentifiers;
|
|
261
|
+
return entry;
|
|
262
|
+
}
|
|
263
|
+
function sanitizeConversationContext(value) {
|
|
264
|
+
if (!isObject(value))
|
|
265
|
+
return undefined;
|
|
266
|
+
const context = {};
|
|
267
|
+
const threadIdentifier = optionalString(value.threadIdentifier);
|
|
268
|
+
if (threadIdentifier)
|
|
269
|
+
context.threadIdentifier = threadIdentifier;
|
|
270
|
+
if (Array.isArray(value.entries)) {
|
|
271
|
+
const entries = value.entries
|
|
272
|
+
.map((entry) => sanitizeConversationEntry(entry))
|
|
273
|
+
.filter((entry) => entry !== null);
|
|
274
|
+
if (entries.length > 0)
|
|
275
|
+
context.entries = entries;
|
|
276
|
+
}
|
|
277
|
+
const selfIdentifiers = optionalStringArray(value.selfIdentifiers);
|
|
278
|
+
if (selfIdentifiers)
|
|
279
|
+
context.selfIdentifiers = selfIdentifiers;
|
|
280
|
+
const responsePrimaryRecipientIdentifiers = optionalStringArray(value.responsePrimaryRecipientIdentifiers);
|
|
281
|
+
if (responsePrimaryRecipientIdentifiers) {
|
|
282
|
+
context.responsePrimaryRecipientIdentifiers = responsePrimaryRecipientIdentifiers;
|
|
283
|
+
}
|
|
284
|
+
const participantNameByIdentifier = optionalStringRecord(value.participantNameByIdentifier);
|
|
285
|
+
if (participantNameByIdentifier) {
|
|
286
|
+
context.participantNameByIdentifier = participantNameByIdentifier;
|
|
287
|
+
}
|
|
288
|
+
if (Object.keys(context).length === 0)
|
|
289
|
+
return undefined;
|
|
290
|
+
return context;
|
|
291
|
+
}
|
|
292
|
+
function sanitizeKeyboardRequest(value) {
|
|
293
|
+
if (!isObject(value))
|
|
294
|
+
return null;
|
|
295
|
+
if (typeof value.prompt !== "string")
|
|
296
|
+
return null;
|
|
297
|
+
const request = { prompt: value.prompt };
|
|
298
|
+
if (typeof value.selectedText === "string") {
|
|
299
|
+
request.selectedText = clampText(value.selectedText, MAX_SELECTED_TEXT_CHARS);
|
|
300
|
+
}
|
|
301
|
+
if (typeof value.documentContextBeforeInput === "string") {
|
|
302
|
+
request.documentContextBeforeInput = clampText(value.documentContextBeforeInput, MAX_CONTEXT_SIDE_CHARS);
|
|
303
|
+
}
|
|
304
|
+
if (typeof value.documentContextAfterInput === "string") {
|
|
305
|
+
request.documentContextAfterInput = clampText(value.documentContextAfterInput, MAX_CONTEXT_SIDE_CHARS);
|
|
306
|
+
}
|
|
307
|
+
if (typeof value.documentIdentifier === "string") {
|
|
308
|
+
request.documentIdentifier = value.documentIdentifier;
|
|
309
|
+
}
|
|
310
|
+
const conversationContext = sanitizeConversationContext(value.conversationContext);
|
|
311
|
+
if (conversationContext)
|
|
312
|
+
request.conversationContext = conversationContext;
|
|
313
|
+
return request;
|
|
314
|
+
}
|
|
315
|
+
function buildKeyboardPrompt(input) {
|
|
316
|
+
const sections = [
|
|
317
|
+
"You are drafting a reply on behalf of the user in another app.",
|
|
318
|
+
"Return only the reply text the user can send. Do not add explanations or labels.",
|
|
319
|
+
];
|
|
320
|
+
if (typeof input.selectedText === "string" && input.selectedText.trim().length > 0) {
|
|
321
|
+
sections.push(`Selected text (highest priority context):\n${input.selectedText}`);
|
|
322
|
+
}
|
|
323
|
+
const entries = input.conversationContext?.entries?.slice(-MAX_CONVERSATION_ENTRIES) ?? [];
|
|
324
|
+
if (entries.length > 0) {
|
|
325
|
+
const lines = entries.map((entry, index) => {
|
|
326
|
+
const sender = entry.senderIdentifier ?? "participant";
|
|
327
|
+
const date = entry.sentDate ? ` (${entry.sentDate})` : "";
|
|
328
|
+
return `${index + 1}. ${sender}${date}: ${entry.text}`;
|
|
329
|
+
});
|
|
330
|
+
sections.push(`Recent conversation entries:\n${lines.join("\n")}`);
|
|
331
|
+
}
|
|
332
|
+
const before = input.documentContextBeforeInput?.trim() ?? "";
|
|
333
|
+
const after = input.documentContextAfterInput?.trim() ?? "";
|
|
334
|
+
if (before.length > 0 || after.length > 0) {
|
|
335
|
+
sections.push(["Nearby text context:", `Before cursor: ${before || "(none)"}`, `After cursor: ${after || "(none)"}`].join("\n"));
|
|
336
|
+
}
|
|
337
|
+
if (typeof input.documentIdentifier === "string" && input.documentIdentifier.trim().length > 0) {
|
|
338
|
+
sections.push(`Document identifier: ${input.documentIdentifier}`);
|
|
339
|
+
}
|
|
340
|
+
sections.push(`User instruction:\n${input.prompt}`);
|
|
341
|
+
return sections.join("\n\n");
|
|
342
|
+
}
|
|
343
|
+
function extractClaudeTextDelta(event) {
|
|
344
|
+
if (!isObject(event) || event.type !== "stream_event")
|
|
345
|
+
return null;
|
|
346
|
+
if (!isObject(event.event) || event.event.type !== "content_block_delta")
|
|
347
|
+
return null;
|
|
348
|
+
if (!isObject(event.event.delta) || event.event.delta.type !== "text_delta")
|
|
349
|
+
return null;
|
|
350
|
+
if (typeof event.event.delta.text !== "string")
|
|
351
|
+
return null;
|
|
352
|
+
return event.event.delta.text;
|
|
353
|
+
}
|
|
354
|
+
function extractClaudeResultText(event) {
|
|
355
|
+
if (!isObject(event) || event.type !== "result")
|
|
356
|
+
return null;
|
|
357
|
+
if (typeof event.result !== "string")
|
|
358
|
+
return null;
|
|
359
|
+
return event.result;
|
|
360
|
+
}
|
|
361
|
+
function runKeyboardPrompt(prompt) {
|
|
362
|
+
return new Promise((resolve) => {
|
|
363
|
+
const startedAt = Date.now();
|
|
364
|
+
let timedOut = false;
|
|
365
|
+
let resultText = "";
|
|
366
|
+
let deltaText = "";
|
|
367
|
+
let lastStderr = "";
|
|
368
|
+
const child = spawn("claude", [
|
|
369
|
+
"-p",
|
|
370
|
+
prompt,
|
|
371
|
+
"--output-format",
|
|
372
|
+
"stream-json",
|
|
373
|
+
"--verbose",
|
|
374
|
+
"--include-partial-messages",
|
|
375
|
+
"--permission-mode",
|
|
376
|
+
"bypassPermissions",
|
|
377
|
+
"--tools",
|
|
378
|
+
"default",
|
|
379
|
+
], {
|
|
380
|
+
cwd: PROJECTS_ROOT,
|
|
381
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
382
|
+
env: process.env,
|
|
383
|
+
});
|
|
384
|
+
const timeout = setTimeout(() => {
|
|
385
|
+
timedOut = true;
|
|
386
|
+
child.kill("SIGKILL");
|
|
387
|
+
}, KEYBOARD_TIMEOUT_MS);
|
|
388
|
+
const rlOut = readline.createInterface({ input: child.stdout });
|
|
389
|
+
rlOut.on("line", (line) => {
|
|
390
|
+
if (!line.trim())
|
|
391
|
+
return;
|
|
392
|
+
const event = JSON.parse(line);
|
|
393
|
+
const result = extractClaudeResultText(event);
|
|
394
|
+
if (typeof result === "string") {
|
|
395
|
+
resultText = result;
|
|
396
|
+
}
|
|
397
|
+
const delta = extractClaudeTextDelta(event);
|
|
398
|
+
if (typeof delta === "string") {
|
|
399
|
+
deltaText += delta;
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
const rlErr = readline.createInterface({ input: child.stderr });
|
|
403
|
+
rlErr.on("line", (line) => {
|
|
404
|
+
if (!line.trim())
|
|
405
|
+
return;
|
|
406
|
+
lastStderr = line;
|
|
407
|
+
});
|
|
408
|
+
child.on("exit", (exitCode, signal) => {
|
|
409
|
+
clearTimeout(timeout);
|
|
410
|
+
rlOut.close();
|
|
411
|
+
rlErr.close();
|
|
412
|
+
const durationMs = Date.now() - startedAt;
|
|
413
|
+
if (timedOut) {
|
|
414
|
+
resolve({
|
|
415
|
+
ok: false,
|
|
416
|
+
statusCode: 502,
|
|
417
|
+
error: `Claude timed out after ${KEYBOARD_TIMEOUT_MS / 1000}s`,
|
|
418
|
+
durationMs,
|
|
419
|
+
});
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (signal) {
|
|
423
|
+
resolve({
|
|
424
|
+
ok: false,
|
|
425
|
+
statusCode: 502,
|
|
426
|
+
error: `Claude exited with signal ${signal}`,
|
|
427
|
+
durationMs,
|
|
428
|
+
});
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (exitCode !== 0) {
|
|
432
|
+
resolve({
|
|
433
|
+
ok: false,
|
|
434
|
+
statusCode: 502,
|
|
435
|
+
error: lastStderr || `Claude exited with code ${String(exitCode)}`,
|
|
436
|
+
durationMs,
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const reply = (resultText.trim().length > 0 ? resultText : deltaText).trim();
|
|
441
|
+
if (reply.length === 0) {
|
|
442
|
+
resolve({
|
|
443
|
+
ok: false,
|
|
444
|
+
statusCode: 502,
|
|
445
|
+
error: "Claude returned an empty reply",
|
|
446
|
+
durationMs,
|
|
447
|
+
});
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
resolve({ ok: true, reply, durationMs });
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
function fileExtension(fileName, mimeType) {
|
|
455
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
456
|
+
if (ext)
|
|
457
|
+
return ext;
|
|
458
|
+
if (mimeType === "image/png")
|
|
459
|
+
return ".png";
|
|
460
|
+
if (mimeType === "image/gif")
|
|
461
|
+
return ".gif";
|
|
462
|
+
if (mimeType === "image/webp")
|
|
463
|
+
return ".webp";
|
|
464
|
+
if (mimeType === "image/heic")
|
|
465
|
+
return ".heic";
|
|
466
|
+
return ".jpg";
|
|
467
|
+
}
|
|
468
|
+
async function uploadImage(state, body) {
|
|
469
|
+
const chat = state.chats.find((entry) => entry.id === body.chatId);
|
|
470
|
+
if (!chat) {
|
|
471
|
+
throw new Error("Unknown chat");
|
|
472
|
+
}
|
|
473
|
+
const project = state.projects.find((entry) => entry.id === chat.projectId);
|
|
474
|
+
if (!project) {
|
|
475
|
+
throw new Error("Unknown project");
|
|
476
|
+
}
|
|
477
|
+
const projectPath = normalizeProjectPath(project.path);
|
|
478
|
+
const relativeDirFs = path.join(".claude", "tmp", chat.id);
|
|
479
|
+
const relativeDir = relativeDirFs.split(path.sep).join("/");
|
|
480
|
+
const id = crypto.randomUUID();
|
|
481
|
+
const ext = fileExtension(body.fileName, body.mimeType);
|
|
482
|
+
const storedFileName = `${id}${ext}`;
|
|
483
|
+
const relativePath = `${relativeDir}/${storedFileName}`;
|
|
484
|
+
const absoluteDir = path.join(projectPath, relativeDirFs);
|
|
485
|
+
const absolutePath = path.join(projectPath, relativePath);
|
|
486
|
+
const bytes = Buffer.from(body.base64, "base64");
|
|
487
|
+
await fs.mkdir(absoluteDir, { recursive: true });
|
|
488
|
+
await fs.writeFile(absolutePath, bytes);
|
|
489
|
+
return {
|
|
490
|
+
id,
|
|
491
|
+
name: body.fileName,
|
|
492
|
+
mimeType: body.mimeType,
|
|
493
|
+
relativePath,
|
|
494
|
+
sizeBytes: bytes.byteLength,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function buildClaudePrompt(text, attachments, projectPath) {
|
|
498
|
+
if (attachments.length === 0)
|
|
499
|
+
return text;
|
|
500
|
+
const attachmentLines = attachments.map((attachment, index) => `${index + 1}. ${path.join(projectPath, attachment.relativePath)} (${attachment.mimeType})`);
|
|
501
|
+
const textPart = text.trim().length > 0 ? text : "Please analyze the attached image.";
|
|
502
|
+
return `${textPart}\n\nAttached files:\n${attachmentLines.join("\n")}`;
|
|
503
|
+
}
|
|
504
|
+
function wsUrlForHost(hostWithPort, secure) {
|
|
505
|
+
return `${secure ? "wss" : "ws"}://${hostWithPort}/ws`;
|
|
506
|
+
}
|
|
507
|
+
function connectLinkForServer(serverUrl) {
|
|
508
|
+
return `mobile://connect?server=${encodeURIComponent(serverUrl)}`;
|
|
509
|
+
}
|
|
510
|
+
function connectPageUrlForServer(hostWithPort, secure) {
|
|
511
|
+
return `${secure ? "https" : "http"}://${hostWithPort}/connect`;
|
|
512
|
+
}
|
|
513
|
+
function escapeHtml(value) {
|
|
514
|
+
return value
|
|
515
|
+
.replaceAll("&", "&")
|
|
516
|
+
.replaceAll("<", "<")
|
|
517
|
+
.replaceAll(">", ">")
|
|
518
|
+
.replaceAll('"', """)
|
|
519
|
+
.replaceAll("'", "'");
|
|
520
|
+
}
|
|
521
|
+
async function main() {
|
|
522
|
+
await fs.mkdir(PROJECTS_ROOT, { recursive: true });
|
|
523
|
+
const suggestedHost = process.env.PUBLIC_HOST ?? `${os.hostname()}:${PORT}`;
|
|
524
|
+
const suggestedWs = wsUrlForHost(suggestedHost, false);
|
|
525
|
+
const suggestedConnectPage = connectPageUrlForServer(suggestedHost, false);
|
|
526
|
+
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}`);
|
|
532
|
+
const active = new Map();
|
|
533
|
+
let state = await loadState();
|
|
534
|
+
state = await normalizeStateProjectPaths(state);
|
|
535
|
+
const handleClientMessage = async (msg, reply) => {
|
|
536
|
+
if (msg.type === "projects.list") {
|
|
537
|
+
reply({ type: "projects.list.result", projects: state.projects });
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (msg.type === "projects.create") {
|
|
541
|
+
const projectPath = msg.path
|
|
542
|
+
? normalizeProjectPath(msg.path)
|
|
543
|
+
: path.resolve(path.join(PROJECTS_ROOT, slugify(msg.name)));
|
|
544
|
+
await fs.mkdir(projectPath, { recursive: true });
|
|
545
|
+
const project = { id: crypto.randomUUID(), name: msg.name, path: projectPath, createdAt: nowIso() };
|
|
546
|
+
state = { ...state, projects: [...state.projects, project] };
|
|
547
|
+
await saveState(state);
|
|
548
|
+
reply({ type: "projects.create.result", project });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (msg.type === "chats.list") {
|
|
552
|
+
const chats = msg.projectId ? state.chats.filter((c) => c.projectId === msg.projectId) : state.chats;
|
|
553
|
+
reply({ type: "chats.list.result", chats });
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (msg.type === "chats.create") {
|
|
557
|
+
const project = state.projects.find((p) => p.id === msg.projectId);
|
|
558
|
+
if (!project)
|
|
559
|
+
throw new Error("Unknown project");
|
|
560
|
+
const chat = {
|
|
561
|
+
id: crypto.randomUUID(),
|
|
562
|
+
projectId: msg.projectId,
|
|
563
|
+
title: msg.title,
|
|
564
|
+
sessionId: null,
|
|
565
|
+
createdAt: nowIso(),
|
|
566
|
+
};
|
|
567
|
+
state = { ...state, chats: [...state.chats, chat] };
|
|
568
|
+
await saveState(state);
|
|
569
|
+
reply({ type: "chats.create.result", chat });
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (msg.type === "chats.history") {
|
|
573
|
+
const chat = state.chats.find((c) => c.id === msg.chatId);
|
|
574
|
+
if (!chat)
|
|
575
|
+
throw new Error("Unknown chat");
|
|
576
|
+
if (!chat.sessionId) {
|
|
577
|
+
reply({ type: "chats.history.result", chatId: chat.id, messages: [], events: [] });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const project = state.projects.find((p) => p.id === chat.projectId);
|
|
581
|
+
if (!project)
|
|
582
|
+
throw new Error("Unknown project");
|
|
583
|
+
const projectPath = normalizeProjectPath(project.path);
|
|
584
|
+
const history = await loadClaudeHistory(projectPath, chat.sessionId);
|
|
585
|
+
reply({
|
|
586
|
+
type: "chats.history.result",
|
|
587
|
+
chatId: chat.id,
|
|
588
|
+
messages: history.messages,
|
|
589
|
+
events: history.events,
|
|
590
|
+
});
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (msg.type === "chats.cancel") {
|
|
594
|
+
const child = active.get(msg.chatId);
|
|
595
|
+
if (child)
|
|
596
|
+
child.kill("SIGINT");
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (msg.type === "upload-image") {
|
|
600
|
+
const attachment = await uploadImage(state, msg);
|
|
601
|
+
reply({ type: "upload-image.result", attachment });
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (msg.type === "chats.send") {
|
|
605
|
+
const chat = state.chats.find((c) => c.id === msg.chatId);
|
|
606
|
+
if (!chat)
|
|
607
|
+
throw new Error("Unknown chat");
|
|
608
|
+
if (active.has(chat.id))
|
|
609
|
+
throw new Error("Chat is busy");
|
|
610
|
+
const project = state.projects.find((p) => p.id === chat.projectId);
|
|
611
|
+
if (!project)
|
|
612
|
+
throw new Error("Unknown project");
|
|
613
|
+
const projectPath = normalizeProjectPath(project.path);
|
|
614
|
+
const prompt = buildClaudePrompt(msg.text, msg.attachments ?? [], projectPath);
|
|
615
|
+
const args = [];
|
|
616
|
+
if (chat.sessionId)
|
|
617
|
+
args.push("-r", chat.sessionId);
|
|
618
|
+
args.push("-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--permission-mode", "bypassPermissions", "--tools", "default");
|
|
619
|
+
const child = spawn("claude", args, {
|
|
620
|
+
cwd: projectPath,
|
|
621
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
622
|
+
env: process.env,
|
|
623
|
+
});
|
|
624
|
+
active.set(chat.id, child);
|
|
625
|
+
const onLine = async (line) => {
|
|
626
|
+
if (!line.trim())
|
|
627
|
+
return;
|
|
628
|
+
const event = JSON.parse(line);
|
|
629
|
+
reply({ type: "claude.event", chatId: chat.id, event });
|
|
630
|
+
const maybe = event;
|
|
631
|
+
if (maybe.type === "system" && maybe.subtype === "init" && typeof maybe.session_id === "string") {
|
|
632
|
+
const idx = state.chats.findIndex((c) => c.id === chat.id);
|
|
633
|
+
if (idx === -1)
|
|
634
|
+
return;
|
|
635
|
+
const existing = state.chats[idx];
|
|
636
|
+
if (!existing)
|
|
637
|
+
return;
|
|
638
|
+
const updated = { ...existing, sessionId: maybe.session_id };
|
|
639
|
+
state = {
|
|
640
|
+
...state,
|
|
641
|
+
chats: [...state.chats.slice(0, idx), updated, ...state.chats.slice(idx + 1)],
|
|
642
|
+
};
|
|
643
|
+
await saveState(state);
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
const rlOut = readline.createInterface({ input: child.stdout });
|
|
647
|
+
rlOut.on("line", (line) => void onLine(line));
|
|
648
|
+
const rlErr = readline.createInterface({ input: child.stderr });
|
|
649
|
+
rlErr.on("line", (line) => reply({ type: "claude.event", chatId: chat.id, event: { type: "stderr", line } }));
|
|
650
|
+
child.on("exit", (exitCode, signal) => {
|
|
651
|
+
active.delete(chat.id);
|
|
652
|
+
rlOut.close();
|
|
653
|
+
rlErr.close();
|
|
654
|
+
reply({ type: "claude.done", chatId: chat.id, exitCode, signal });
|
|
655
|
+
});
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const unreachable = msg;
|
|
659
|
+
throw new Error(`Unhandled message: ${unreachable.type}`);
|
|
660
|
+
};
|
|
661
|
+
const httpServer = http.createServer(async (req, res) => {
|
|
662
|
+
const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
663
|
+
const protoHeader = req.headers["x-forwarded-proto"];
|
|
664
|
+
const proto = Array.isArray(protoHeader) ? protoHeader[0] : protoHeader;
|
|
665
|
+
const secure = proto === "https";
|
|
666
|
+
const hostHeader = req.headers["x-forwarded-host"] ?? req.headers.host ?? `localhost:${PORT}`;
|
|
667
|
+
const hostWithPort = (Array.isArray(hostHeader) ? hostHeader[0] : hostHeader) ?? `localhost:${PORT}`;
|
|
668
|
+
if (req.method === "GET" && requestUrl.pathname === "/health") {
|
|
669
|
+
jsonResponse(res, 200, { ok: true, serverVersion: "0.1.0" });
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (req.method === "GET" && requestUrl.pathname === "/connect") {
|
|
673
|
+
const explicitServer = requestUrl.searchParams.get("server");
|
|
674
|
+
const serverUrl = explicitServer ?? wsUrlForHost(hostWithPort, secure);
|
|
675
|
+
const deepLink = connectLinkForServer(serverUrl);
|
|
676
|
+
const qrCodeImage = await QRCode.toDataURL(deepLink, {
|
|
677
|
+
width: 320,
|
|
678
|
+
margin: 1,
|
|
679
|
+
});
|
|
680
|
+
const manualConnectPage = connectPageUrlForServer(hostWithPort, secure);
|
|
681
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
682
|
+
res.end(`<!doctype html>
|
|
683
|
+
<html lang="en">
|
|
684
|
+
<head>
|
|
685
|
+
<meta charset="utf-8" />
|
|
686
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
687
|
+
<title>cc-bridge Connect</title>
|
|
688
|
+
<style>
|
|
689
|
+
:root {
|
|
690
|
+
color-scheme: light;
|
|
691
|
+
}
|
|
692
|
+
body {
|
|
693
|
+
margin: 0;
|
|
694
|
+
min-height: 100vh;
|
|
695
|
+
display: grid;
|
|
696
|
+
place-items: center;
|
|
697
|
+
background: radial-gradient(circle at 10% 20%, #fff7ed, #f5f5f4 45%, #e7e5e4);
|
|
698
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
699
|
+
color: #1c1917;
|
|
700
|
+
}
|
|
701
|
+
.card {
|
|
702
|
+
width: min(680px, calc(100vw - 32px));
|
|
703
|
+
background: #ffffff;
|
|
704
|
+
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
705
|
+
border-radius: 20px;
|
|
706
|
+
padding: 22px;
|
|
707
|
+
box-sizing: border-box;
|
|
708
|
+
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.08);
|
|
709
|
+
}
|
|
710
|
+
h1 {
|
|
711
|
+
margin: 0 0 6px;
|
|
712
|
+
font-size: 28px;
|
|
713
|
+
line-height: 1.1;
|
|
714
|
+
}
|
|
715
|
+
p {
|
|
716
|
+
margin: 0;
|
|
717
|
+
color: #57534e;
|
|
718
|
+
line-height: 1.45;
|
|
719
|
+
}
|
|
720
|
+
.layout {
|
|
721
|
+
display: grid;
|
|
722
|
+
gap: 20px;
|
|
723
|
+
margin-top: 18px;
|
|
724
|
+
}
|
|
725
|
+
@media (min-width: 680px) {
|
|
726
|
+
.layout {
|
|
727
|
+
grid-template-columns: 320px 1fr;
|
|
728
|
+
align-items: center;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
.qr {
|
|
732
|
+
width: 100%;
|
|
733
|
+
max-width: 320px;
|
|
734
|
+
justify-self: center;
|
|
735
|
+
border-radius: 16px;
|
|
736
|
+
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
737
|
+
background: #fff;
|
|
738
|
+
}
|
|
739
|
+
.steps {
|
|
740
|
+
display: grid;
|
|
741
|
+
gap: 10px;
|
|
742
|
+
}
|
|
743
|
+
.step {
|
|
744
|
+
display: grid;
|
|
745
|
+
grid-template-columns: 24px 1fr;
|
|
746
|
+
gap: 10px;
|
|
747
|
+
align-items: start;
|
|
748
|
+
}
|
|
749
|
+
.num {
|
|
750
|
+
width: 24px;
|
|
751
|
+
height: 24px;
|
|
752
|
+
border-radius: 999px;
|
|
753
|
+
background: #b45309;
|
|
754
|
+
color: #fff;
|
|
755
|
+
font-size: 13px;
|
|
756
|
+
font-weight: 700;
|
|
757
|
+
display: grid;
|
|
758
|
+
place-items: center;
|
|
759
|
+
}
|
|
760
|
+
.mono {
|
|
761
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
762
|
+
padding: 10px 12px;
|
|
763
|
+
border-radius: 12px;
|
|
764
|
+
background: #f5f5f4;
|
|
765
|
+
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
766
|
+
margin-top: 10px;
|
|
767
|
+
font-size: 12px;
|
|
768
|
+
line-height: 1.35;
|
|
769
|
+
word-break: break-all;
|
|
770
|
+
}
|
|
771
|
+
.buttons {
|
|
772
|
+
margin-top: 12px;
|
|
773
|
+
display: flex;
|
|
774
|
+
flex-wrap: wrap;
|
|
775
|
+
gap: 8px;
|
|
776
|
+
}
|
|
777
|
+
button,
|
|
778
|
+
a.button {
|
|
779
|
+
border: 0;
|
|
780
|
+
border-radius: 10px;
|
|
781
|
+
padding: 10px 14px;
|
|
782
|
+
background: #1c1917;
|
|
783
|
+
color: #fff;
|
|
784
|
+
font-weight: 600;
|
|
785
|
+
cursor: pointer;
|
|
786
|
+
text-decoration: none;
|
|
787
|
+
}
|
|
788
|
+
a.secondary {
|
|
789
|
+
background: #e7e5e4;
|
|
790
|
+
color: #292524;
|
|
791
|
+
}
|
|
792
|
+
</style>
|
|
793
|
+
</head>
|
|
794
|
+
<body>
|
|
795
|
+
<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>
|
|
798
|
+
|
|
799
|
+
<div class="layout">
|
|
800
|
+
<img class="qr" src="${qrCodeImage}" alt="QR code to connect mobile app" />
|
|
801
|
+
<div class="steps">
|
|
802
|
+
<div class="step">
|
|
803
|
+
<div class="num">1</div>
|
|
804
|
+
<p>Open your iPhone camera and scan this QR code.</p>
|
|
805
|
+
</div>
|
|
806
|
+
<div class="step">
|
|
807
|
+
<div class="num">2</div>
|
|
808
|
+
<p>Tap the banner to open the cc-bridge app.</p>
|
|
809
|
+
</div>
|
|
810
|
+
<div class="step">
|
|
811
|
+
<div class="num">3</div>
|
|
812
|
+
<p>It stores the bridge server URL and reconnects automatically.</p>
|
|
813
|
+
</div>
|
|
814
|
+
|
|
815
|
+
<div class="mono" id="server-url">${escapeHtml(serverUrl)}</div>
|
|
816
|
+
<div class="buttons">
|
|
817
|
+
<button id="copy-server">Copy Server URL</button>
|
|
818
|
+
<a class="button secondary" href="${escapeHtml(deepLink)}">Open Link</a>
|
|
819
|
+
<a class="button secondary" href="${escapeHtml(manualConnectPage)}">Refresh</a>
|
|
820
|
+
</div>
|
|
821
|
+
</div>
|
|
822
|
+
</div>
|
|
823
|
+
</div>
|
|
824
|
+
<script>
|
|
825
|
+
const button = document.getElementById("copy-server");
|
|
826
|
+
const value = document.getElementById("server-url")?.textContent || "";
|
|
827
|
+
button?.addEventListener("click", async () => {
|
|
828
|
+
await navigator.clipboard.writeText(value);
|
|
829
|
+
button.textContent = "Copied";
|
|
830
|
+
setTimeout(() => {
|
|
831
|
+
button.textContent = "Copy Server URL";
|
|
832
|
+
}, 1200);
|
|
833
|
+
});
|
|
834
|
+
</script>
|
|
835
|
+
</body>
|
|
836
|
+
</html>`);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (req.method === "POST" && requestUrl.pathname === "/upload-image") {
|
|
840
|
+
const payload = await readJsonBody(req);
|
|
841
|
+
const body = parseUploadImageBody(payload);
|
|
842
|
+
if (!body) {
|
|
843
|
+
jsonResponse(res, 400, { error: "Invalid upload-image payload" });
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
const chat = state.chats.find((entry) => entry.id === body.chatId);
|
|
847
|
+
if (!chat) {
|
|
848
|
+
jsonResponse(res, 404, { error: "Unknown chat" });
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
const project = state.projects.find((entry) => entry.id === chat.projectId);
|
|
852
|
+
if (!project) {
|
|
853
|
+
jsonResponse(res, 404, { error: "Unknown project" });
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const attachment = await uploadImage(state, body);
|
|
857
|
+
jsonResponse(res, 200, { attachment });
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
if (req.method === "POST" && requestUrl.pathname === "/keyboard/respond") {
|
|
861
|
+
const payload = await readJsonBody(req);
|
|
862
|
+
const body = sanitizeKeyboardRequest(payload);
|
|
863
|
+
if (!body) {
|
|
864
|
+
jsonResponse(res, 400, { error: "Invalid keyboard payload" });
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (body.prompt.trim().length === 0) {
|
|
868
|
+
jsonResponse(res, 400, { error: "prompt is required" });
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
const synthesizedPrompt = buildKeyboardPrompt(body);
|
|
872
|
+
const outcome = await runKeyboardPrompt(synthesizedPrompt);
|
|
873
|
+
if (!outcome.ok) {
|
|
874
|
+
jsonResponse(res, outcome.statusCode, { error: outcome.error });
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const response = {
|
|
878
|
+
reply: outcome.reply,
|
|
879
|
+
durationMs: outcome.durationMs,
|
|
880
|
+
};
|
|
881
|
+
jsonResponse(res, 200, response);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
885
|
+
res.end("cc-bridge\n");
|
|
886
|
+
});
|
|
887
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
888
|
+
httpServer.on("upgrade", (req, socket, head) => {
|
|
889
|
+
if (req.url !== "/ws") {
|
|
890
|
+
socket.destroy();
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
|
|
894
|
+
});
|
|
895
|
+
wss.on("connection", (ws) => {
|
|
896
|
+
ws.on("message", async (data) => {
|
|
897
|
+
const msg = parseMessage(data);
|
|
898
|
+
await handleClientMessage(msg, (message) => send(ws, message));
|
|
899
|
+
});
|
|
900
|
+
});
|
|
901
|
+
if (RELAY_URL) {
|
|
902
|
+
const relaySession = await loadRelaySession();
|
|
903
|
+
const relayClient = new RelayClient({
|
|
904
|
+
relayUrl: RELAY_URL,
|
|
905
|
+
session: relaySession,
|
|
906
|
+
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.");
|
|
909
|
+
},
|
|
910
|
+
onPaired: async (session) => {
|
|
911
|
+
await saveRelaySession(session);
|
|
912
|
+
console.log(`[cc-bridge] relay paired: session ${session.sessionId}`);
|
|
913
|
+
},
|
|
914
|
+
onPayload: async (payload) => {
|
|
915
|
+
const msg = parseMessagePayload(payload);
|
|
916
|
+
await handleClientMessage(msg, (message) => relayClient.send(JSON.stringify(message)));
|
|
917
|
+
},
|
|
918
|
+
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
|
+
if (message.type === "relay.error") {
|
|
932
|
+
console.log(`[cc-bridge] relay error: ${message.message}`);
|
|
933
|
+
}
|
|
934
|
+
},
|
|
935
|
+
});
|
|
936
|
+
relayClient.connect();
|
|
937
|
+
console.log(`[cc-bridge] relay: ${RELAY_URL}`);
|
|
938
|
+
}
|
|
939
|
+
httpServer.listen(PORT, HOST);
|
|
940
|
+
}
|
|
941
|
+
void main();
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
const RECONNECT_DELAY_MS = 3_000;
|
|
3
|
+
function isObject(value) {
|
|
4
|
+
return typeof value === "object" && value !== null;
|
|
5
|
+
}
|
|
6
|
+
function isRelayRole(value) {
|
|
7
|
+
return value === "bridge" || value === "mobile";
|
|
8
|
+
}
|
|
9
|
+
function isRelayControlMessage(value) {
|
|
10
|
+
if (!isObject(value) || typeof value.type !== "string")
|
|
11
|
+
return false;
|
|
12
|
+
if (value.type === "relay.registered") {
|
|
13
|
+
return typeof value.code === "string" && typeof value.sessionId === "string";
|
|
14
|
+
}
|
|
15
|
+
if (value.type === "relay.paired") {
|
|
16
|
+
return (typeof value.sessionId === "string" &&
|
|
17
|
+
typeof value.sessionToken === "string" &&
|
|
18
|
+
isRelayRole(value.role));
|
|
19
|
+
}
|
|
20
|
+
if (value.type === "relay.reconnected") {
|
|
21
|
+
return isRelayRole(value.role);
|
|
22
|
+
}
|
|
23
|
+
if (value.type === "relay.peer_connected" || value.type === "relay.peer_disconnected") {
|
|
24
|
+
return isRelayRole(value.peer);
|
|
25
|
+
}
|
|
26
|
+
if (value.type === "relay.error") {
|
|
27
|
+
return typeof value.message === "string";
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
function relayWsUrl(relayUrl, session) {
|
|
32
|
+
const url = new URL(relayUrl);
|
|
33
|
+
url.pathname = "/ws/bridge";
|
|
34
|
+
url.search = "";
|
|
35
|
+
if (session) {
|
|
36
|
+
url.searchParams.set("session", session.sessionId);
|
|
37
|
+
url.searchParams.set("token", session.sessionToken);
|
|
38
|
+
}
|
|
39
|
+
return url.toString();
|
|
40
|
+
}
|
|
41
|
+
function rawDataToText(data) {
|
|
42
|
+
if (typeof data === "string")
|
|
43
|
+
return data;
|
|
44
|
+
if (Array.isArray(data)) {
|
|
45
|
+
return data
|
|
46
|
+
.map((chunk) => chunk instanceof ArrayBuffer
|
|
47
|
+
? Buffer.from(new Uint8Array(chunk)).toString("utf8")
|
|
48
|
+
: Buffer.from(chunk).toString("utf8"))
|
|
49
|
+
.join("");
|
|
50
|
+
}
|
|
51
|
+
if (data instanceof ArrayBuffer)
|
|
52
|
+
return Buffer.from(new Uint8Array(data)).toString("utf8");
|
|
53
|
+
return Buffer.from(data).toString("utf8");
|
|
54
|
+
}
|
|
55
|
+
export class RelayClient {
|
|
56
|
+
options;
|
|
57
|
+
socket = null;
|
|
58
|
+
reconnectTimer = null;
|
|
59
|
+
stopped = false;
|
|
60
|
+
session;
|
|
61
|
+
constructor(options) {
|
|
62
|
+
this.options = options;
|
|
63
|
+
this.session = options.session;
|
|
64
|
+
}
|
|
65
|
+
connect() {
|
|
66
|
+
this.stopped = false;
|
|
67
|
+
this.connectNow();
|
|
68
|
+
}
|
|
69
|
+
close() {
|
|
70
|
+
this.stopped = true;
|
|
71
|
+
if (this.reconnectTimer) {
|
|
72
|
+
clearTimeout(this.reconnectTimer);
|
|
73
|
+
this.reconnectTimer = null;
|
|
74
|
+
}
|
|
75
|
+
if (this.socket) {
|
|
76
|
+
this.socket.close();
|
|
77
|
+
this.socket = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
send(data) {
|
|
81
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
82
|
+
throw new Error("Relay websocket is not open");
|
|
83
|
+
}
|
|
84
|
+
this.socket.send(data);
|
|
85
|
+
}
|
|
86
|
+
connectNow() {
|
|
87
|
+
const socket = new WebSocket(relayWsUrl(this.options.relayUrl, this.session));
|
|
88
|
+
this.socket = socket;
|
|
89
|
+
socket.on("message", async (data) => {
|
|
90
|
+
const payload = JSON.parse(rawDataToText(data));
|
|
91
|
+
if (isRelayControlMessage(payload)) {
|
|
92
|
+
this.handleControlMessage(payload);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
await this.options.onPayload(payload);
|
|
96
|
+
});
|
|
97
|
+
socket.on("close", () => {
|
|
98
|
+
if (this.socket === socket)
|
|
99
|
+
this.socket = null;
|
|
100
|
+
if (this.stopped)
|
|
101
|
+
return;
|
|
102
|
+
this.scheduleReconnect();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
scheduleReconnect() {
|
|
106
|
+
if (this.reconnectTimer)
|
|
107
|
+
return;
|
|
108
|
+
this.reconnectTimer = setTimeout(() => {
|
|
109
|
+
this.reconnectTimer = null;
|
|
110
|
+
this.connectNow();
|
|
111
|
+
}, RECONNECT_DELAY_MS);
|
|
112
|
+
}
|
|
113
|
+
handleControlMessage(message) {
|
|
114
|
+
this.options.onControlMessage?.(message);
|
|
115
|
+
if (message.type === "relay.registered") {
|
|
116
|
+
this.options.onPairingCode(message);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (message.type === "relay.paired" && message.role === "bridge") {
|
|
120
|
+
const session = {
|
|
121
|
+
sessionId: message.sessionId,
|
|
122
|
+
sessionToken: message.sessionToken,
|
|
123
|
+
};
|
|
124
|
+
this.session = session;
|
|
125
|
+
void this.options.onPaired(session);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const STATE_DIR = path.join(os.homedir(), ".cc-bridge");
|
|
6
|
+
const TOKENS_PATH = path.join(STATE_DIR, "tokens.json");
|
|
7
|
+
const STATE_PATH = path.join(STATE_DIR, "state.json");
|
|
8
|
+
const RELAY_SESSION_PATH = path.join(STATE_DIR, "relay-session.json");
|
|
9
|
+
async function ensureStateDir() {
|
|
10
|
+
await fs.mkdir(STATE_DIR, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
export async function loadTokens() {
|
|
13
|
+
await ensureStateDir();
|
|
14
|
+
if (!existsSync(TOKENS_PATH))
|
|
15
|
+
return [];
|
|
16
|
+
const raw = await fs.readFile(TOKENS_PATH, "utf8");
|
|
17
|
+
const parsed = JSON.parse(raw);
|
|
18
|
+
if (!Array.isArray(parsed))
|
|
19
|
+
throw new Error("tokens.json must be an array");
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
export async function saveTokens(tokens) {
|
|
23
|
+
await ensureStateDir();
|
|
24
|
+
await fs.writeFile(TOKENS_PATH, JSON.stringify(tokens, null, 2) + "\n", "utf8");
|
|
25
|
+
}
|
|
26
|
+
export async function loadState() {
|
|
27
|
+
await ensureStateDir();
|
|
28
|
+
if (!existsSync(STATE_PATH))
|
|
29
|
+
return { projects: [], chats: [] };
|
|
30
|
+
const raw = await fs.readFile(STATE_PATH, "utf8");
|
|
31
|
+
const parsed = JSON.parse(raw);
|
|
32
|
+
if (!parsed || typeof parsed !== "object")
|
|
33
|
+
throw new Error("state.json must be an object");
|
|
34
|
+
const o = parsed;
|
|
35
|
+
if (!Array.isArray(o.projects) || !Array.isArray(o.chats)) {
|
|
36
|
+
throw new Error("state.json must have projects[] and chats[]");
|
|
37
|
+
}
|
|
38
|
+
return { projects: o.projects, chats: o.chats };
|
|
39
|
+
}
|
|
40
|
+
export async function saveState(state) {
|
|
41
|
+
await ensureStateDir();
|
|
42
|
+
await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
43
|
+
}
|
|
44
|
+
export async function loadRelaySession() {
|
|
45
|
+
await ensureStateDir();
|
|
46
|
+
if (!existsSync(RELAY_SESSION_PATH))
|
|
47
|
+
return null;
|
|
48
|
+
const raw = await fs.readFile(RELAY_SESSION_PATH, "utf8");
|
|
49
|
+
const parsed = JSON.parse(raw);
|
|
50
|
+
if (!parsed || typeof parsed !== "object") {
|
|
51
|
+
throw new Error("relay-session.json must be an object");
|
|
52
|
+
}
|
|
53
|
+
const maybe = parsed;
|
|
54
|
+
if (typeof maybe.sessionId !== "string" || typeof maybe.sessionToken !== "string") {
|
|
55
|
+
throw new Error("relay-session.json must include sessionId and sessionToken");
|
|
56
|
+
}
|
|
57
|
+
return { sessionId: maybe.sessionId, sessionToken: maybe.sessionToken };
|
|
58
|
+
}
|
|
59
|
+
export async function saveRelaySession(session) {
|
|
60
|
+
await ensureStateDir();
|
|
61
|
+
await fs.writeFile(RELAY_SESSION_PATH, JSON.stringify(session, null, 2) + "\n", "utf8");
|
|
62
|
+
}
|
|
63
|
+
export async function clearRelaySession() {
|
|
64
|
+
await ensureStateDir();
|
|
65
|
+
if (!existsSync(RELAY_SESSION_PATH))
|
|
66
|
+
return;
|
|
67
|
+
await fs.rm(RELAY_SESSION_PATH);
|
|
68
|
+
}
|
|
69
|
+
export function nowIso() {
|
|
70
|
+
return new Date().toISOString();
|
|
71
|
+
}
|
|
72
|
+
export function tokenRecord(token, deviceName) {
|
|
73
|
+
return { token, deviceName, createdAt: nowIso() };
|
|
74
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jumper-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"jumper-app": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "tsx watch src/index.ts",
|
|
14
|
+
"start": "node dist/cli.js",
|
|
15
|
+
"build": "tsc -p tsconfig.json",
|
|
16
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
17
|
+
"prepack": "pnpm run build"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"qrcode": "^1.5.4",
|
|
21
|
+
"ws": "^8.18.3"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^24.3.0",
|
|
25
|
+
"@types/qrcode": "^1.5.5",
|
|
26
|
+
"@types/ws": "^8.18.1",
|
|
27
|
+
"tsx": "^4.20.5",
|
|
28
|
+
"typescript": "^5.9.2"
|
|
29
|
+
}
|
|
30
|
+
}
|