claude-simple 0.2.17 → 0.2.19
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/client.js +257 -7
- package/dist/{app.0.2.17.min.js → app.0.2.19.min.js} +71 -71
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/public/chat.js +33 -0
- package/public/index.html +2 -2
- package/public/shell/hub.js +24 -14
- package/public/shell/session-list.js +7 -4
- package/public/sw.js +3 -3
- package/public/ui.min.css +1 -1
- /package/dist/{style.0.2.17.min.css → style.0.2.19.min.css} +0 -0
package/client.js
CHANGED
|
@@ -4,16 +4,243 @@ import { startAutoUpdate } from './src/auto-update.js';
|
|
|
4
4
|
import { WebSocket } from 'ws';
|
|
5
5
|
import os from 'os';
|
|
6
6
|
import crypto from 'crypto';
|
|
7
|
+
import { promises as fs, watch as watchFs } from 'fs';
|
|
8
|
+
import path from 'path';
|
|
7
9
|
|
|
8
10
|
const GATEWAY_URL = process.env.GATEWAY_URL; // e.g. wss://your-server.com/machine-connect
|
|
9
11
|
const MACHINE_ID = process.env.MACHINE_ID || os.hostname();
|
|
10
12
|
const MACHINE_TOKEN = process.env.MACHINE_TOKEN; // shared secret with gateway
|
|
11
13
|
const LOCAL_PORT = parseInt(process.env.LOCAL_PORT || '13000');
|
|
14
|
+
const SYNC_DIR = process.env.EDGE_SYNC_DIR || path.join(os.homedir(), '.claude-simple-edge');
|
|
15
|
+
const SYNC_FILE = path.join(SYNC_DIR, 'hub-stream-outbox.json');
|
|
12
16
|
|
|
13
17
|
if (!GATEWAY_URL) { console.error('GATEWAY_URL is required'); process.exit(1); }
|
|
14
18
|
if (!MACHINE_TOKEN) { console.error('MACHINE_TOKEN is required'); process.exit(1); }
|
|
15
19
|
|
|
20
|
+
// Hub ACKed stream outbox. One sequence represents one already-batched UI
|
|
21
|
+
// frame, not an individual token, keeping both radio traffic and local disk
|
|
22
|
+
// use bounded.
|
|
23
|
+
let syncState = { version: 2, next: {}, pending: [], catalog: {}, catalogPending: null, catalogSeq: 0 };
|
|
24
|
+
let syncWrite = Promise.resolve();
|
|
25
|
+
let syncSent = new Set();
|
|
26
|
+
|
|
27
|
+
async function loadSyncState() {
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(await fs.readFile(SYNC_FILE, 'utf8'));
|
|
30
|
+
if (parsed && typeof parsed === 'object') {
|
|
31
|
+
syncState = {
|
|
32
|
+
version: 2,
|
|
33
|
+
next: parsed.next || {},
|
|
34
|
+
pending: Array.isArray(parsed.pending) ? parsed.pending.slice(-20000) : [],
|
|
35
|
+
// Keeping the last compact catalog locally means reconnects do not
|
|
36
|
+
// resend every session. Only file-driven changes become deltas.
|
|
37
|
+
catalog: parsed.catalog && typeof parsed.catalog === 'object' ? parsed.catalog : {},
|
|
38
|
+
catalogPending: parsed.catalogPending && typeof parsed.catalogPending === 'object' ? parsed.catalogPending : null,
|
|
39
|
+
catalogSeq: Number(parsed.catalogSeq) || 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
} catch { /* first boot */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function persistSyncState() {
|
|
46
|
+
const snapshot = JSON.stringify(syncState);
|
|
47
|
+
syncWrite = syncWrite.then(async () => {
|
|
48
|
+
await fs.mkdir(SYNC_DIR, { recursive: true, mode: 0o700 });
|
|
49
|
+
await fs.writeFile(`${SYNC_FILE}.tmp`, snapshot, { mode: 0o600 });
|
|
50
|
+
await fs.rename(`${SYNC_FILE}.tmp`, SYNC_FILE);
|
|
51
|
+
}).catch(e => console.error('[sync] persist failed:', e.message));
|
|
52
|
+
return syncWrite;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function syncKey(sessionId, agent) { return `${MACHINE_ID}:${agent}:${sessionId}`; }
|
|
56
|
+
|
|
57
|
+
function queueSyncFrame({ sessionId, agent, cwd, frame }) {
|
|
58
|
+
if (!sessionId || !agent || !frame) return 0;
|
|
59
|
+
const key = syncKey(sessionId, agent);
|
|
60
|
+
const seq = (Number(syncState.next[key]) || 0) + 1;
|
|
61
|
+
syncState.next[key] = seq;
|
|
62
|
+
syncState.pending.push({ sessionId, agent, cwd: cwd || '', seq, frame, ts: Date.now() });
|
|
63
|
+
if (syncState.pending.length > 20000) syncState.pending.splice(0, syncState.pending.length - 20000);
|
|
64
|
+
void persistSyncState().then(flushSyncOutbox);
|
|
65
|
+
return seq;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function flushSyncOutbox() {
|
|
69
|
+
if (controlWs?.readyState !== WebSocket.OPEN) return;
|
|
70
|
+
for (const event of syncState.pending) {
|
|
71
|
+
const id = `${event.agent}:${event.sessionId}:${event.seq}`;
|
|
72
|
+
if (syncSent.has(id)) continue;
|
|
73
|
+
syncSent.add(id);
|
|
74
|
+
sendControl({ type: 'sync-event', data: JSON.stringify(event) });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function acknowledgeSync(data) {
|
|
79
|
+
const upto = Number(data?.upto) || 0;
|
|
80
|
+
if (!data?.sessionId || !data?.agent || !upto) return;
|
|
81
|
+
const before = syncState.pending.length;
|
|
82
|
+
syncState.pending = syncState.pending.filter(e => !(e.sessionId === data.sessionId && e.agent === data.agent && e.seq <= upto));
|
|
83
|
+
if (before !== syncState.pending.length) void persistSyncState();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function flushCatalogOutbox() {
|
|
87
|
+
const pending = syncState.catalogPending;
|
|
88
|
+
if (controlWs?.readyState !== WebSocket.OPEN || !pending) return;
|
|
89
|
+
sendControl({ type: 'sync-catalog', data: JSON.stringify({ id: pending.id, upserts: pending.upserts || [], removes: pending.removes || [] }) });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function acknowledgeCatalog(data) {
|
|
93
|
+
const pending = syncState.catalogPending;
|
|
94
|
+
if (!pending?.next || typeof pending.next !== 'object' || data?.id !== pending.id) return;
|
|
95
|
+
syncState.catalog = pending.next;
|
|
96
|
+
syncState.catalogPending = null;
|
|
97
|
+
void persistSyncState();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Session catalog sync ───────────────────────────────────────────────────
|
|
101
|
+
// The Hub owns the durable catalog. An Edge only sends a compact upsert/remove
|
|
102
|
+
// delta after an on-disk session change; it never streams transcripts here.
|
|
103
|
+
const SESSION_WATCH_ROOTS = [
|
|
104
|
+
...String(process.env.CLAUDE_CONFIG_DIRS || path.join(os.homedir(), '.claude')).split(',')
|
|
105
|
+
.map(v => path.join(v.trim().replace(/^~/, os.homedir()), 'projects')),
|
|
106
|
+
path.join((process.env.CODEX_HOME || path.join(os.homedir(), '.codex')).replace(/^~/, os.homedir()), 'sessions'),
|
|
107
|
+
path.join((process.env.GROK_HOME || path.join(os.homedir(), '.grok')).replace(/^~/, os.homedir()), 'sessions'),
|
|
108
|
+
path.join((process.env.GEMINI_HOME || path.join(os.homedir(), '.gemini')).replace(/^~/, os.homedir()), 'antigravity-cli'),
|
|
109
|
+
path.join((process.env.CURSOR_HOME || path.join(os.homedir(), '.cursor')).replace(/^~/, os.homedir()), 'claude-simple-ui'),
|
|
110
|
+
];
|
|
111
|
+
const catalogRoots = [...new Set(SESSION_WATCH_ROOTS)];
|
|
112
|
+
let catalogRefreshTimer = null;
|
|
113
|
+
let catalogRefreshing = false;
|
|
114
|
+
let catalogRefreshAgain = false;
|
|
115
|
+
let catalogFallbackTimer = null;
|
|
116
|
+
let catalogWatcherStarted = false;
|
|
117
|
+
const catalogRootStats = new Map();
|
|
118
|
+
|
|
119
|
+
function catalogKey(session) {
|
|
120
|
+
return `${session.agent || 'claude'}:${session.sessionId || ''}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function compactSession(session) {
|
|
124
|
+
const out = {};
|
|
125
|
+
// Deliberately whitelist the sidebar/session-open fields: no transcript,
|
|
126
|
+
// tool input, file path or arbitrary adapter data is retained on the Hub.
|
|
127
|
+
for (const key of ['sessionId', 'agent', 'cwd', 'projectName', 'display', 'configDir', 'updatedAt', 'createdAt', 'turnCount', 'totalTokens', 'hasMemory', 'model']) {
|
|
128
|
+
const value = session?.[key];
|
|
129
|
+
if (value !== undefined && value !== null && value !== '') out[key] = value;
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function refreshSessionCatalog() {
|
|
135
|
+
if (catalogRefreshing) {
|
|
136
|
+
catalogRefreshAgain = true;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (controlWs?.readyState !== WebSocket.OPEN) return;
|
|
140
|
+
catalogRefreshing = true;
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetch(`http://127.0.0.1:${LOCAL_PORT}/api/sessions?limit=800`, {
|
|
143
|
+
headers: { 'X-Hub-Token': MACHINE_TOKEN, 'Accept-Encoding': 'identity' },
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok) throw new Error(`session scan returned ${res.status}`);
|
|
146
|
+
const list = await res.json();
|
|
147
|
+
const next = {};
|
|
148
|
+
for (const raw of Array.isArray(list) ? list : []) {
|
|
149
|
+
const session = compactSession(raw);
|
|
150
|
+
const key = catalogKey(session);
|
|
151
|
+
if (session.sessionId) next[key] = session;
|
|
152
|
+
}
|
|
153
|
+
const before = syncState.catalog || {};
|
|
154
|
+
const upserts = Object.entries(next)
|
|
155
|
+
.filter(([key, value]) => JSON.stringify(before[key]) !== JSON.stringify(value))
|
|
156
|
+
.map(([, value]) => value);
|
|
157
|
+
const removes = Object.keys(before).filter(key => !next[key]);
|
|
158
|
+
if (upserts.length || removes.length) {
|
|
159
|
+
// Keep the outgoing delta on disk until the Hub confirms it. Retrying
|
|
160
|
+
// is safe because Hub-side upserts/removes are idempotent.
|
|
161
|
+
const id = ++syncState.catalogSeq;
|
|
162
|
+
syncState.catalogPending = { id, upserts, removes, next };
|
|
163
|
+
void persistSyncState();
|
|
164
|
+
flushCatalogOutbox();
|
|
165
|
+
console.log(`[catalog] synced +${upserts.length} -${removes.length}`);
|
|
166
|
+
}
|
|
167
|
+
} catch (e) {
|
|
168
|
+
console.error('[catalog] refresh failed:', e.message);
|
|
169
|
+
} finally {
|
|
170
|
+
catalogRefreshing = false;
|
|
171
|
+
if (catalogRefreshAgain) {
|
|
172
|
+
catalogRefreshAgain = false;
|
|
173
|
+
void refreshSessionCatalog();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function scheduleSessionCatalogRefresh(delay = 300) {
|
|
179
|
+
clearTimeout(catalogRefreshTimer);
|
|
180
|
+
catalogRefreshTimer = setTimeout(() => void refreshSessionCatalog(), delay);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function rootFingerprint(root) {
|
|
184
|
+
// The fallback intentionally reads directory metadata only. It does not
|
|
185
|
+
// parse JSON/JSONL or walk the session tree. Native watches catch nested
|
|
186
|
+
// appends; this probe catches root replacement/remounts and dropped events.
|
|
187
|
+
try {
|
|
188
|
+
const stat = await fs.stat(root);
|
|
189
|
+
return `${stat.size}:${Math.floor(stat.mtimeMs)}:${Math.floor(stat.ctimeMs)}`;
|
|
190
|
+
} catch {
|
|
191
|
+
return 'missing';
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function startSessionCatalogWatch() {
|
|
196
|
+
if (catalogWatcherStarted) return;
|
|
197
|
+
catalogWatcherStarted = true;
|
|
198
|
+
let watches = 0;
|
|
199
|
+
for (const root of catalogRoots) {
|
|
200
|
+
try {
|
|
201
|
+
const watcher = watchFs(root, { recursive: true }, () => scheduleSessionCatalogRefresh());
|
|
202
|
+
watcher.on('error', () => {});
|
|
203
|
+
watches++;
|
|
204
|
+
} catch {
|
|
205
|
+
// The 2-second metadata probe below covers platforms/filesystems where
|
|
206
|
+
// recursive native watching is unavailable (network mounts, older Linux).
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// A cheap two-second probe also covers dropped native events. It only queues
|
|
210
|
+
// a real scan when a fingerprint differs, so quiet machines do no parsing.
|
|
211
|
+
catalogFallbackTimer = setInterval(async () => {
|
|
212
|
+
for (const root of catalogRoots) {
|
|
213
|
+
const next = await rootFingerprint(root);
|
|
214
|
+
const prev = catalogRootStats.get(root);
|
|
215
|
+
catalogRootStats.set(root, next);
|
|
216
|
+
if (prev !== undefined && prev !== next) scheduleSessionCatalogRefresh();
|
|
217
|
+
}
|
|
218
|
+
}, 2000);
|
|
219
|
+
console.log(`[catalog] watching ${watches}/${catalogRoots.length} roots; 2s metadata fallback enabled`);
|
|
220
|
+
scheduleSessionCatalogRefresh(0);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function stampRuntimeFrame(tunnel, text) {
|
|
224
|
+
if (tunnel?.path !== '/ws/chat') return text;
|
|
225
|
+
let frame;
|
|
226
|
+
try { frame = JSON.parse(text); } catch { return text; }
|
|
227
|
+
if (frame.type === 'ping' || frame.type === 'pong' || frame.type === 'hub-turn-finished') return text;
|
|
228
|
+
const inspect = (value) => {
|
|
229
|
+
if (!value || typeof value !== 'object') return;
|
|
230
|
+
if (value.type === 'batch' && Array.isArray(value.items)) { value.items.forEach(inspect); return; }
|
|
231
|
+
if (value.sessionId) tunnel.sessionId = value.sessionId;
|
|
232
|
+
if (value.agent) tunnel.agent = value.agent;
|
|
233
|
+
};
|
|
234
|
+
inspect(frame);
|
|
235
|
+
if (!tunnel.sessionId || !tunnel.agent) return text;
|
|
236
|
+
const seq = queueSyncFrame({ sessionId: tunnel.sessionId, agent: tunnel.agent, cwd: tunnel.cwd, frame: text });
|
|
237
|
+
if (!seq) return text;
|
|
238
|
+
frame.hubSeq = seq;
|
|
239
|
+
return JSON.stringify(frame);
|
|
240
|
+
}
|
|
241
|
+
|
|
16
242
|
// ─── Start local app (only binds to 127.0.0.1) ───────────────────────────────
|
|
243
|
+
await loadSyncState();
|
|
17
244
|
await init();
|
|
18
245
|
const { server } = createApp();
|
|
19
246
|
await new Promise(resolve => server.listen(LOCAL_PORT, '127.0.0.1', resolve));
|
|
@@ -23,7 +250,7 @@ console.log(`[client] Local app listening on 127.0.0.1:${LOCAL_PORT}`);
|
|
|
23
250
|
// Requires global install + systemd Restart= (or equivalent) for full apply.
|
|
24
251
|
startAutoUpdate({ role: 'edge-client', isBusy: hasBusyAgent });
|
|
25
252
|
|
|
26
|
-
// ─── Active WS tunnels:
|
|
253
|
+
// ─── Active WS tunnels: socket + session identity for resumable frames ───────
|
|
27
254
|
const tunnels = new Map();
|
|
28
255
|
|
|
29
256
|
// ─── Gateway connection ───────────────────────────────────────────────────────
|
|
@@ -69,18 +296,32 @@ function connect() {
|
|
|
69
296
|
|
|
70
297
|
controlWs.on('open', () => {
|
|
71
298
|
reconnectDelay = 1000;
|
|
299
|
+
syncSent = new Set();
|
|
72
300
|
console.log(`[client] Connected. Registering as "${MACHINE_ID}"`);
|
|
73
301
|
sendControl({
|
|
74
302
|
type: 'register',
|
|
75
303
|
machineId: MACHINE_ID,
|
|
76
304
|
meta: { hostname: os.hostname(), platform: os.platform() },
|
|
77
305
|
});
|
|
306
|
+
flushSyncOutbox();
|
|
307
|
+
flushCatalogOutbox();
|
|
308
|
+
startSessionCatalogWatch();
|
|
309
|
+
scheduleSessionCatalogRefresh(0);
|
|
78
310
|
});
|
|
79
311
|
|
|
80
312
|
controlWs.on('message', async (raw) => {
|
|
81
313
|
let msg;
|
|
82
314
|
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
83
315
|
|
|
316
|
+
if (msg.type === 'sync-ack') {
|
|
317
|
+
try { acknowledgeSync(JSON.parse(msg.data || '{}')); } catch { /* malformed ack */ }
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (msg.type === 'sync-catalog-ack') {
|
|
321
|
+
try { acknowledgeCatalog(JSON.parse(msg.data || '{}')); } catch { /* malformed ack */ }
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
84
325
|
// ── HTTP proxy ────────────────────────────────────────────────────────────
|
|
85
326
|
if (msg.type === 'http-req') {
|
|
86
327
|
try {
|
|
@@ -170,14 +411,22 @@ function connect() {
|
|
|
170
411
|
const { tunnelId, path, query } = msg;
|
|
171
412
|
const localUrl = `ws://127.0.0.1:${LOCAL_PORT}${path}${query || ''}`;
|
|
172
413
|
const localWs = new WebSocket(localUrl);
|
|
414
|
+
const params = new URLSearchParams(String(query || '').replace(/^\?/, ''));
|
|
415
|
+
const tunnel = {
|
|
416
|
+
ws: localWs,
|
|
417
|
+
path,
|
|
418
|
+
sessionId: params.get('sessionId') || '',
|
|
419
|
+
agent: params.get('agent') || '',
|
|
420
|
+
cwd: params.get('cwd') || '',
|
|
421
|
+
};
|
|
173
422
|
|
|
174
423
|
localWs.on('open', () => {
|
|
175
|
-
tunnels.set(tunnelId,
|
|
424
|
+
tunnels.set(tunnelId, tunnel);
|
|
176
425
|
sendControl({ type: 'ws-ready', tunnelId });
|
|
177
426
|
});
|
|
178
427
|
|
|
179
428
|
localWs.on('message', (data) => {
|
|
180
|
-
sendControl({ type: 'ws-msg', tunnelId, data: data.toString() });
|
|
429
|
+
sendControl({ type: 'ws-msg', tunnelId, data: stampRuntimeFrame(tunnel, data.toString()) });
|
|
181
430
|
});
|
|
182
431
|
|
|
183
432
|
localWs.on('close', () => {
|
|
@@ -195,16 +444,16 @@ function connect() {
|
|
|
195
444
|
|
|
196
445
|
// ── WS tunnel message (gateway → local) ───────────────────────────────────
|
|
197
446
|
if (msg.type === 'ws-msg') {
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
447
|
+
const tunnel = tunnels.get(msg.tunnelId);
|
|
448
|
+
if (tunnel?.ws?.readyState === WebSocket.OPEN) tunnel.ws.send(msg.data);
|
|
200
449
|
return;
|
|
201
450
|
}
|
|
202
451
|
|
|
203
452
|
// ── WS tunnel close (gateway side closed) ─────────────────────────────────
|
|
204
453
|
if (msg.type === 'ws-close') {
|
|
205
|
-
const
|
|
454
|
+
const tunnel = tunnels.get(msg.tunnelId);
|
|
206
455
|
tunnels.delete(msg.tunnelId);
|
|
207
|
-
if (
|
|
456
|
+
if (tunnel?.ws?.readyState === WebSocket.OPEN) tunnel.ws.close();
|
|
208
457
|
return;
|
|
209
458
|
}
|
|
210
459
|
|
|
@@ -214,6 +463,7 @@ function connect() {
|
|
|
214
463
|
});
|
|
215
464
|
|
|
216
465
|
controlWs.on('close', () => {
|
|
466
|
+
syncSent = new Set();
|
|
217
467
|
console.log(`[client] Disconnected. Reconnecting in ${reconnectDelay}ms ...`);
|
|
218
468
|
// Close all open tunnels
|
|
219
469
|
for (const localWs of tunnels.values()) {
|