claude-simple 0.2.18 → 0.2.20

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,7 +4,7 @@ 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';
7
+ import { promises as fs, watch as watchFs } from 'fs';
8
8
  import path from 'path';
9
9
 
10
10
  const GATEWAY_URL = process.env.GATEWAY_URL; // e.g. wss://your-server.com/machine-connect
@@ -20,7 +20,7 @@ if (!MACHINE_TOKEN) { console.error('MACHINE_TOKEN is required'); process.exit(
20
20
  // Hub ACKed stream outbox. One sequence represents one already-batched UI
21
21
  // frame, not an individual token, keeping both radio traffic and local disk
22
22
  // use bounded.
23
- let syncState = { version: 1, next: {}, pending: [] };
23
+ let syncState = { version: 2, next: {}, pending: [], catalog: {}, catalogPending: null, catalogSeq: 0 };
24
24
  let syncWrite = Promise.resolve();
25
25
  let syncSent = new Set();
26
26
 
@@ -28,7 +28,16 @@ async function loadSyncState() {
28
28
  try {
29
29
  const parsed = JSON.parse(await fs.readFile(SYNC_FILE, 'utf8'));
30
30
  if (parsed && typeof parsed === 'object') {
31
- syncState = { version: 1, next: parsed.next || {}, pending: Array.isArray(parsed.pending) ? parsed.pending.slice(-20000) : [] };
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
+ };
32
41
  }
33
42
  } catch { /* first boot */ }
34
43
  }
@@ -74,6 +83,143 @@ function acknowledgeSync(data) {
74
83
  if (before !== syncState.pending.length) void persistSyncState();
75
84
  }
76
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
+
77
223
  function stampRuntimeFrame(tunnel, text) {
78
224
  if (tunnel?.path !== '/ws/chat') return text;
79
225
  let frame;
@@ -158,6 +304,9 @@ function connect() {
158
304
  meta: { hostname: os.hostname(), platform: os.platform() },
159
305
  });
160
306
  flushSyncOutbox();
307
+ flushCatalogOutbox();
308
+ startSessionCatalogWatch();
309
+ scheduleSessionCatalogRefresh(0);
161
310
  });
162
311
 
163
312
  controlWs.on('message', async (raw) => {
@@ -168,6 +317,10 @@ function connect() {
168
317
  try { acknowledgeSync(JSON.parse(msg.data || '{}')); } catch { /* malformed ack */ }
169
318
  return;
170
319
  }
320
+ if (msg.type === 'sync-catalog-ack') {
321
+ try { acknowledgeCatalog(JSON.parse(msg.data || '{}')); } catch { /* malformed ack */ }
322
+ return;
323
+ }
171
324
 
172
325
  // ── HTTP proxy ────────────────────────────────────────────────────────────
173
326
  if (msg.type === 'http-req') {