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 +156 -3
- package/dist/{app.0.2.18.min.js → app.0.2.20.min.js} +57 -57
- package/dist/index.html +2 -2
- package/package.json +1 -1
- 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/src/app.js +13 -1
- /package/dist/{style.0.2.18.min.css → style.0.2.20.min.css} +0 -0
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:
|
|
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 = {
|
|
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') {
|