claude-simple 0.2.31 → 0.2.32

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/index.html CHANGED
@@ -4,13 +4,13 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
6
6
  <title>Claude Code</title>
7
- <link rel="stylesheet" href="/style.0.2.31.min.css">
7
+ <link rel="stylesheet" href="/style.0.2.32.min.css">
8
8
  <link rel="stylesheet" href="/highlight.min.css">
9
9
  <script src="/marked.min.js"></script>
10
10
  <script src="/highlight.min.js"></script>
11
11
  </head>
12
12
  <body class="h-dvh overflow-hidden flex flex-col bg-base-100 text-base-content">
13
13
  <div id="root" class="flex flex-col flex-1 overflow-hidden"></div>
14
- <script type="module" src="/app.0.2.31.min.js"></script>
14
+ <script type="module" src="/app.0.2.32.min.js"></script>
15
15
  </body>
16
16
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-simple",
3
- "version": "0.2.31",
3
+ "version": "0.2.32",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude-simple": "server.js",
package/public/cache.js CHANGED
@@ -29,17 +29,53 @@ export async function getCachedTranscript(key) {
29
29
  catch { return null; }
30
30
  }
31
31
 
32
- export async function setCachedTranscript(key, transcript) {
33
- try { await db.set(`transcript:${key}`, recentTranscript(transcript), { ttl: 24 * 60 * 60 * 1000 }); }
34
- catch {}
32
+ let transcriptWrites = Promise.resolve();
33
+ export function setCachedTranscript(key, transcript) {
34
+ const recent = recentTranscript(transcript);
35
+ transcriptWrites = transcriptWrites.catch(() => {}).then(async () => {
36
+ await db.set(`transcript:${key}`, recent, { ttl: 24 * 60 * 60 * 1000 });
37
+ const currentKey = `transcript:${key}`;
38
+ const previous = await db.get('transcript-index-v2');
39
+ const index = [
40
+ { key: currentKey, at: Date.now() },
41
+ ...(previous || []).filter(entry => entry.key !== currentKey),
42
+ ].filter(entry => entry.at >= Date.now() - 24 * 60 * 60 * 1000).slice(0, 24);
43
+ const keep = new Set(index.map(entry => entry.key));
44
+ // Enumerate keys only: loading every legacy transcript here freezes the UI.
45
+ for (const storedKey of await db.keys()) {
46
+ if (String(storedKey).startsWith('transcript:') && !keep.has(storedKey)) await db.delete(storedKey);
47
+ }
48
+ await db.set('transcript-index-v2', index);
49
+ });
50
+ return transcriptWrites.catch(() => {});
35
51
  }
36
52
 
37
53
  // lastSessionContext lives in shell/session-context.js (avoid stale cache.js in browsers)
38
54
 
55
+ // Conservative UTF-8 estimate avoids stringifying huge messages on the UI thread.
56
+ export function cacheWeight(value, budget = 1024 * 1024) {
57
+ if (typeof value === 'string') return value.length * 3 + 2;
58
+ if (!value || typeof value !== 'object') return 16;
59
+ let size = 2;
60
+ for (const [key,item] of Object.entries(value)) {
61
+ size += key.length * 3 + cacheWeight(item, budget - size) + 4;
62
+ if (size > budget) break;
63
+ }
64
+ return size;
65
+ }
39
66
  export function recentTranscript(snapshot) {
40
67
  if (!snapshot?.msgs) return snapshot ?? null;
41
- const drop = Math.max(0, snapshot.msgs.length - 80);
42
- return { ...snapshot, msgs: snapshot.msgs.slice(drop), meta: {
68
+ let drop = snapshot.msgs.length;
69
+ let bytes = cacheWeight(snapshot.context);
70
+ const context = bytes > 64 * 1024 ? null : snapshot.context;
71
+ bytes = cacheWeight(context);
72
+ while (drop > 0 && snapshot.msgs.length - drop < 80) {
73
+ const weight = cacheWeight(snapshot.msgs[drop - 1]);
74
+ if (bytes + weight > 1024 * 1024) break;
75
+ bytes += weight;
76
+ drop--;
77
+ }
78
+ return { ...snapshot, context, msgs: snapshot.msgs.slice(drop), meta: {
43
79
  ...snapshot.meta, oldestIndex: (snapshot.meta?.oldestIndex || 0) + drop,
44
80
  truncated: drop > 0 || !!snapshot.meta?.truncated,
45
81
  } };
package/public/files.js CHANGED
@@ -394,12 +394,33 @@ async function downloadFile(proj, filePath) {
394
394
  const qs = new URLSearchParams({ root, path: filePath, download: 'true' });
395
395
  const url = `/api/projects/${id}/file?${qs}`;
396
396
  try {
397
+ const handle = typeof window.showSaveFilePicker === 'function'
398
+ ? await window.showSaveFilePicker({ suggestedName: name }) : null;
397
399
  const res = await fetch(url, { headers: authHeaders(), credentials: 'same-origin' });
398
400
  if (!res.ok) {
399
401
  const d = await res.json().catch(() => ({}));
400
402
  throw new Error(d.error || `HTTP ${res.status}`);
401
403
  }
402
- const blob = await res.blob();
404
+ if (handle && res.body) {
405
+ await res.body.pipeTo(await handle.createWritable());
406
+ return;
407
+ }
408
+ // Browsers without a file sink hand large downloads to their download manager.
409
+ const maxBlob = 16 * 1024 * 1024;
410
+ if (Number(res.headers.get('content-length')) > maxBlob) {
411
+ await res.body?.cancel();
412
+ throw new Error('Use native download');
413
+ }
414
+ const reader = res.body.getReader();
415
+ const chunks = []; let bytes = 0;
416
+ for (;;) {
417
+ const { done, value } = await reader.read();
418
+ if (done) break;
419
+ bytes += value.byteLength;
420
+ if (bytes > maxBlob) { await reader.cancel(); throw new Error('Use native download'); }
421
+ chunks.push(value);
422
+ }
423
+ const blob = new Blob(chunks, { type: res.headers.get('content-type') || 'application/octet-stream' });
403
424
  const objectUrl = URL.createObjectURL(blob);
404
425
  const a = document.createElement('a');
405
426
  a.href = objectUrl;
@@ -412,6 +433,7 @@ async function downloadFile(proj, filePath) {
412
433
  // iOS may need the URL briefly; revoke after a beat.
413
434
  setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
414
435
  } catch (e) {
436
+ if (e.name === 'AbortError') return;
415
437
  // Last resort: open with token query (works when fetch is blocked, e.g. some WebViews).
416
438
  try {
417
439
  const fallback = `/api/projects/${id}/file?${authQuery({ root, path: filePath, download: 'true' })}`;
package/public/index.html CHANGED
@@ -85,7 +85,7 @@
85
85
  <script src="/vendor/mermaid.min.js"></script>
86
86
  <link rel="stylesheet" href="/vendor/katex.min.css">
87
87
  <script src="/vendor/katex.min.js"></script>
88
- <link rel="stylesheet" href="style.css?v=ui-25">
88
+ <link rel="stylesheet" href="style.css?v=ui-26">
89
89
  </head>
90
90
  <body class="h-dvh overflow-hidden flex flex-col bg-base-100 text-base-content">
91
91
  <div id="root" class="flex flex-col flex-1 overflow-hidden"></div>
@@ -101,6 +101,6 @@
101
101
  }
102
102
  }, 10000);
103
103
  </script>
104
- <script type="module" src="app.js?v=ui-25"></script>
104
+ <script type="module" src="app.js?v=ui-26"></script>
105
105
  </body>
106
106
  </html>
@@ -374,7 +374,9 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
374
374
  historyWindow = { msgs, context, meta };
375
375
  if (msgs.length && !meta.stale) {
376
376
  const snapshot = { msgs, context, meta, at: Date.now() };
377
+ historyCache.delete(cacheKey);
377
378
  historyCache.set(cacheKey, recentTranscript(snapshot));
379
+ while (historyCache.size > 24) historyCache.delete(historyCache.keys().next().value);
378
380
  setCachedTranscript(cacheKey, snapshot).catch(() => {});
379
381
  }
380
382
  else historyCache.delete(cacheKey);
package/public/sw.js CHANGED
@@ -1,12 +1,12 @@
1
1
  /* Agent UI service worker — cache shell assets only.
2
2
  * Never intercepts WebSocket or mutates API semantics beyond offline fallback.
3
3
  * Bump CACHE when shipping UI that must invalidate old shells. */
4
- const CACHE = 'agent-ui-shell-v34';
4
+ const CACHE = 'agent-ui-shell-v35';
5
5
  const PRECACHE = [
6
6
  '/',
7
7
  '/index.html',
8
8
  '/manifest.webmanifest',
9
- '/style.css?v=ui-25',
9
+ '/style.css?v=ui-26',
10
10
  '/style.css',
11
11
  '/ui.min.css',
12
12
  '/vendor/mini-react.min.js',
@@ -16,7 +16,7 @@ const PRECACHE = [
16
16
  '/vendor/mermaid.min.js',
17
17
  '/vendor/katex.min.js',
18
18
  '/vendor/katex.min.css',
19
- '/app.js?v=ui-25',
19
+ '/app.js?v=ui-26',
20
20
  '/icons/icon-192.png',
21
21
  '/icons/icon-512.png',
22
22
  '/icons/icon-maskable-512.png',
@@ -165,7 +165,33 @@ function isInternal(text) {
165
165
  return INTERNAL_PREFIXES.some(p => text.startsWith(p));
166
166
  }
167
167
 
168
+ // Hot native projections avoid re-parsing settled Claude rollouts. In-flight
169
+ // reads coalesce; stat validation keeps appended/truncated files authoritative.
170
+ const claudeTranscripts = new Map();
168
171
  export async function parseClaudeMessages(filePath) {
172
+ const stat = await fs.stat(filePath);
173
+ const stamp = `${stat.ino}:${stat.size}:${stat.mtimeMs}`;
174
+ const cached = claudeTranscripts.get(filePath);
175
+ if (cached?.stamp === stamp) {
176
+ claudeTranscripts.delete(filePath); claudeTranscripts.set(filePath, cached);
177
+ return cached.loading || cached.messages;
178
+ }
179
+ const entry = { stamp, weight: stat.size };
180
+ entry.loading = parseClaudeMessagesUncached(filePath);
181
+ claudeTranscripts.set(filePath, entry);
182
+ try { entry.messages = await entry.loading; return entry.messages; }
183
+ finally {
184
+ entry.loading = null;
185
+ let total = [...claudeTranscripts.values()].reduce((sum,e) => sum + e.weight, 0);
186
+ for (const [key,value] of claudeTranscripts) {
187
+ if (claudeTranscripts.size <= 8 && total <= 32 * 1024 * 1024) break;
188
+ if (value.loading) continue;
189
+ total -= value.weight; claudeTranscripts.delete(key);
190
+ }
191
+ }
192
+ }
193
+
194
+ async function parseClaudeMessagesUncached(filePath) {
169
195
  const messages = [];
170
196
  let pendingUsage = null;
171
197
 
package/src/app.js CHANGED
@@ -1058,7 +1058,9 @@ export function createApp() {
1058
1058
  res.setHeader('Content-Disposition', `attachment; filename="${ascii || 'download'}"; filename*=UTF-8''${star}`);
1059
1059
  res.setHeader('Content-Type', 'application/octet-stream');
1060
1060
  res.setHeader('Cache-Control', 'no-store');
1061
- return fsSync.createReadStream(filePath).pipe(res);
1061
+ return res.sendFile(filePath, { dotfiles: 'allow' }, err => {
1062
+ if (err && !res.headersSent && !res.destroyed) res.status(err.status || 500).json({ error: err.message });
1063
+ });
1062
1064
  }
1063
1065
  // Office / PDF / binary previews need more headroom than plain text
1064
1066
  const isOffice = /\.(docx|pptx|xlsx|pdf)$/i.test(rel);
@@ -0,0 +1,70 @@
1
+ import { WebSocket } from 'ws';
2
+ import { setTimeout as sleep } from 'node:timers/promises';
3
+
4
+ // Aggregate wire budget across downloads/previews, separate from chat traffic.
5
+ export class TransferBudget {
6
+ constructor(bytesPerSecond = 256 * 1024) {
7
+ this.rate = bytesPerSecond;
8
+ this.next = 0;
9
+ }
10
+ async take(bytes, signal) {
11
+ const now = Date.now();
12
+ const start = Math.max(now, this.next);
13
+ this.next = start + bytes * 1000 / this.rate;
14
+ if (start > now) await sleep(start - now, undefined, { signal });
15
+ }
16
+ }
17
+
18
+ export async function readLimitedBody(response, maxBytes = 8 * 1024 * 1024) {
19
+ const chunks = [];
20
+ let size = 0;
21
+ for await (const value of response.body || []) {
22
+ size += value.byteLength;
23
+ if (size > maxBytes) throw new Error('Response too large; use paginated history or file download');
24
+ chunks.push(Buffer.from(value));
25
+ }
26
+ return Buffer.concat(chunks, size);
27
+ }
28
+
29
+ export function createHTTPTransfer({ gatewayURL, token, machineId, budget = new TransferBudget(), maxConcurrent = 2 }) {
30
+ let active = 0;
31
+ return async function transfer(msg, url, options, sanitizeHeaders) {
32
+ if (active >= maxConcurrent) throw Object.assign(new Error('Too many file transfers; retry after the current download finishes'), { status: 429 });
33
+ active++;
34
+ const controller = new AbortController();
35
+ let ws;
36
+ try {
37
+ ws = new WebSocket(gatewayURL, {
38
+ headers: { 'x-machine-token': token, 'x-http-request': msg.reqId, 'x-machine-id': machineId },
39
+ perMessageDeflate: false, handshakeTimeout: 10000,
40
+ });
41
+ ws.on('close', () => controller.abort());
42
+ ws.on('error', () => controller.abort());
43
+ await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); });
44
+ const send = data => new Promise((resolve, reject) => {
45
+ if (ws.readyState !== WebSocket.OPEN) return reject(new Error('Transfer connection closed'));
46
+ ws.send(JSON.stringify({ ...data, reqId: msg.reqId }), err => err ? reject(err) : resolve());
47
+ });
48
+ try {
49
+ const res = await fetch(url, { ...options, signal: controller.signal });
50
+ await send({ type: 'http-res-start', status: res.status, headers: sanitizeHeaders(Object.fromEntries(res.headers.entries())) });
51
+ for await (const value of res.body || []) {
52
+ // Each frame is small; sends wait for socket backpressure and share a rate cap.
53
+ for (let offset = 0; offset < value.byteLength; offset += 24 * 1024) {
54
+ const chunk = Buffer.from(value.subarray(offset, offset + 24 * 1024));
55
+ await budget.take(Math.ceil(chunk.length * 4 / 3) + 128, controller.signal);
56
+ await send({ type: 'http-chunk', data: chunk.toString('base64'), encoding: 'base64' });
57
+ }
58
+ }
59
+ await send({ type: 'http-end' });
60
+ } catch (err) {
61
+ if (ws.readyState === WebSocket.OPEN) await send({ type: 'http-error', message: err.message }).catch(() => {});
62
+ throw err;
63
+ }
64
+ } finally {
65
+ controller.abort();
66
+ ws?.close();
67
+ active--;
68
+ }
69
+ };
70
+ }