claude-simple 0.2.17 → 0.2.18

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 CHANGED
@@ -4,16 +4,97 @@ 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 } 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: 1, next: {}, pending: [] };
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 = { version: 1, next: parsed.next || {}, pending: Array.isArray(parsed.pending) ? parsed.pending.slice(-20000) : [] };
32
+ }
33
+ } catch { /* first boot */ }
34
+ }
35
+
36
+ function persistSyncState() {
37
+ const snapshot = JSON.stringify(syncState);
38
+ syncWrite = syncWrite.then(async () => {
39
+ await fs.mkdir(SYNC_DIR, { recursive: true, mode: 0o700 });
40
+ await fs.writeFile(`${SYNC_FILE}.tmp`, snapshot, { mode: 0o600 });
41
+ await fs.rename(`${SYNC_FILE}.tmp`, SYNC_FILE);
42
+ }).catch(e => console.error('[sync] persist failed:', e.message));
43
+ return syncWrite;
44
+ }
45
+
46
+ function syncKey(sessionId, agent) { return `${MACHINE_ID}:${agent}:${sessionId}`; }
47
+
48
+ function queueSyncFrame({ sessionId, agent, cwd, frame }) {
49
+ if (!sessionId || !agent || !frame) return 0;
50
+ const key = syncKey(sessionId, agent);
51
+ const seq = (Number(syncState.next[key]) || 0) + 1;
52
+ syncState.next[key] = seq;
53
+ syncState.pending.push({ sessionId, agent, cwd: cwd || '', seq, frame, ts: Date.now() });
54
+ if (syncState.pending.length > 20000) syncState.pending.splice(0, syncState.pending.length - 20000);
55
+ void persistSyncState().then(flushSyncOutbox);
56
+ return seq;
57
+ }
58
+
59
+ function flushSyncOutbox() {
60
+ if (controlWs?.readyState !== WebSocket.OPEN) return;
61
+ for (const event of syncState.pending) {
62
+ const id = `${event.agent}:${event.sessionId}:${event.seq}`;
63
+ if (syncSent.has(id)) continue;
64
+ syncSent.add(id);
65
+ sendControl({ type: 'sync-event', data: JSON.stringify(event) });
66
+ }
67
+ }
68
+
69
+ function acknowledgeSync(data) {
70
+ const upto = Number(data?.upto) || 0;
71
+ if (!data?.sessionId || !data?.agent || !upto) return;
72
+ const before = syncState.pending.length;
73
+ syncState.pending = syncState.pending.filter(e => !(e.sessionId === data.sessionId && e.agent === data.agent && e.seq <= upto));
74
+ if (before !== syncState.pending.length) void persistSyncState();
75
+ }
76
+
77
+ function stampRuntimeFrame(tunnel, text) {
78
+ if (tunnel?.path !== '/ws/chat') return text;
79
+ let frame;
80
+ try { frame = JSON.parse(text); } catch { return text; }
81
+ if (frame.type === 'ping' || frame.type === 'pong' || frame.type === 'hub-turn-finished') return text;
82
+ const inspect = (value) => {
83
+ if (!value || typeof value !== 'object') return;
84
+ if (value.type === 'batch' && Array.isArray(value.items)) { value.items.forEach(inspect); return; }
85
+ if (value.sessionId) tunnel.sessionId = value.sessionId;
86
+ if (value.agent) tunnel.agent = value.agent;
87
+ };
88
+ inspect(frame);
89
+ if (!tunnel.sessionId || !tunnel.agent) return text;
90
+ const seq = queueSyncFrame({ sessionId: tunnel.sessionId, agent: tunnel.agent, cwd: tunnel.cwd, frame: text });
91
+ if (!seq) return text;
92
+ frame.hubSeq = seq;
93
+ return JSON.stringify(frame);
94
+ }
95
+
16
96
  // ─── Start local app (only binds to 127.0.0.1) ───────────────────────────────
97
+ await loadSyncState();
17
98
  await init();
18
99
  const { server } = createApp();
19
100
  await new Promise(resolve => server.listen(LOCAL_PORT, '127.0.0.1', resolve));
@@ -23,7 +104,7 @@ console.log(`[client] Local app listening on 127.0.0.1:${LOCAL_PORT}`);
23
104
  // Requires global install + systemd Restart= (or equivalent) for full apply.
24
105
  startAutoUpdate({ role: 'edge-client', isBusy: hasBusyAgent });
25
106
 
26
- // ─── Active WS tunnels: tunnelId → local WebSocket ───────────────────────────
107
+ // ─── Active WS tunnels: socket + session identity for resumable frames ───────
27
108
  const tunnels = new Map();
28
109
 
29
110
  // ─── Gateway connection ───────────────────────────────────────────────────────
@@ -69,18 +150,25 @@ function connect() {
69
150
 
70
151
  controlWs.on('open', () => {
71
152
  reconnectDelay = 1000;
153
+ syncSent = new Set();
72
154
  console.log(`[client] Connected. Registering as "${MACHINE_ID}"`);
73
155
  sendControl({
74
156
  type: 'register',
75
157
  machineId: MACHINE_ID,
76
158
  meta: { hostname: os.hostname(), platform: os.platform() },
77
159
  });
160
+ flushSyncOutbox();
78
161
  });
79
162
 
80
163
  controlWs.on('message', async (raw) => {
81
164
  let msg;
82
165
  try { msg = JSON.parse(raw.toString()); } catch { return; }
83
166
 
167
+ if (msg.type === 'sync-ack') {
168
+ try { acknowledgeSync(JSON.parse(msg.data || '{}')); } catch { /* malformed ack */ }
169
+ return;
170
+ }
171
+
84
172
  // ── HTTP proxy ────────────────────────────────────────────────────────────
85
173
  if (msg.type === 'http-req') {
86
174
  try {
@@ -170,14 +258,22 @@ function connect() {
170
258
  const { tunnelId, path, query } = msg;
171
259
  const localUrl = `ws://127.0.0.1:${LOCAL_PORT}${path}${query || ''}`;
172
260
  const localWs = new WebSocket(localUrl);
261
+ const params = new URLSearchParams(String(query || '').replace(/^\?/, ''));
262
+ const tunnel = {
263
+ ws: localWs,
264
+ path,
265
+ sessionId: params.get('sessionId') || '',
266
+ agent: params.get('agent') || '',
267
+ cwd: params.get('cwd') || '',
268
+ };
173
269
 
174
270
  localWs.on('open', () => {
175
- tunnels.set(tunnelId, localWs);
271
+ tunnels.set(tunnelId, tunnel);
176
272
  sendControl({ type: 'ws-ready', tunnelId });
177
273
  });
178
274
 
179
275
  localWs.on('message', (data) => {
180
- sendControl({ type: 'ws-msg', tunnelId, data: data.toString() });
276
+ sendControl({ type: 'ws-msg', tunnelId, data: stampRuntimeFrame(tunnel, data.toString()) });
181
277
  });
182
278
 
183
279
  localWs.on('close', () => {
@@ -195,16 +291,16 @@ function connect() {
195
291
 
196
292
  // ── WS tunnel message (gateway → local) ───────────────────────────────────
197
293
  if (msg.type === 'ws-msg') {
198
- const localWs = tunnels.get(msg.tunnelId);
199
- if (localWs?.readyState === WebSocket.OPEN) localWs.send(msg.data);
294
+ const tunnel = tunnels.get(msg.tunnelId);
295
+ if (tunnel?.ws?.readyState === WebSocket.OPEN) tunnel.ws.send(msg.data);
200
296
  return;
201
297
  }
202
298
 
203
299
  // ── WS tunnel close (gateway side closed) ─────────────────────────────────
204
300
  if (msg.type === 'ws-close') {
205
- const localWs = tunnels.get(msg.tunnelId);
301
+ const tunnel = tunnels.get(msg.tunnelId);
206
302
  tunnels.delete(msg.tunnelId);
207
- if (localWs?.readyState === WebSocket.OPEN) localWs.close();
303
+ if (tunnel?.ws?.readyState === WebSocket.OPEN) tunnel.ws.close();
208
304
  return;
209
305
  }
210
306
 
@@ -214,6 +310,7 @@ function connect() {
214
310
  });
215
311
 
216
312
  controlWs.on('close', () => {
313
+ syncSent = new Set();
217
314
  console.log(`[client] Disconnected. Reconnecting in ${reconnectDelay}ms ...`);
218
315
  // Close all open tunnels
219
316
  for (const localWs of tunnels.values()) {