claude-simple 0.2.31 → 0.2.33

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.33.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.33.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.33",
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/chat.js CHANGED
@@ -305,8 +305,9 @@ export function applyComposerAccess() {
305
305
  banner.classList.remove('hidden');
306
306
  banner.textContent = composerBlockReason();
307
307
  } else {
308
- banner.classList.add('hidden');
309
- banner.textContent = '';
308
+ const notice = ctx.nativeRunNotice?.sessionId === ctx.sessionId ? ctx.nativeRunNotice.message : '';
309
+ banner.classList.toggle('hidden', !notice);
310
+ banner.textContent = notice;
310
311
  }
311
312
  }
312
313
  }
@@ -393,6 +394,14 @@ export function connectWS(opts = {}) {
393
394
  return;
394
395
  }
395
396
  if (msg.type === 'run-status') {
397
+ if (msg.sessionId && msg.sessionId !== ctx.sessionId) return;
398
+ if (typeof msg.running !== 'boolean') {
399
+ ctx.nativeRunNotice = { sessionId: ctx.sessionId, message: msg.message || '终端会话未确认结束' };
400
+ applyComposerAccess();
401
+ return;
402
+ }
403
+ ctx.nativeRunNotice = null;
404
+ applyComposerAccess();
396
405
  // Only adopt "running" from server; never re-lock after we already finished a turn
397
406
  // unless the server still has a busy run (reconnect mid-stream).
398
407
  if (msg.running) {
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-27">
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-27"></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-v36';
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-27',
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-27',
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
 
@@ -398,6 +424,18 @@ export async function parseCodexMessages(filePath) {
398
424
  return entry.messages;
399
425
  }
400
426
 
427
+ export async function readCodexRuntime(filePath, now = Date.now()) {
428
+ await parseCodexMessages(filePath);
429
+ const entry = codexTranscripts.get(filePath);
430
+ const runtime = entry?.runtime;
431
+ const pending = runtime?.state === 'pending';
432
+ const state = pending ? (now - entry.mtimeMs < 120_000 ? 'running' : 'unconfirmed') : runtime?.state || 'unknown';
433
+ return { ...runtime, state, running: state === 'running' ? true : ['completed','aborted'].includes(state) ? false : null,
434
+ updatedAt: entry?.mtimeMs || null, source: 'native-jsonl',
435
+ message: state === 'unconfirmed' || state === 'unknown' ? '终端会话未确认结束,请以原始记录核对' : state === 'running' ? '终端会话正在运行' : '终端会话已结束',
436
+ };
437
+ }
438
+
401
439
  async function updateCodexTranscript(filePath, entry, stat) {
402
440
  const messages = entry.messages;
403
441
  // Codex writes the same assistant turn twice:
@@ -441,10 +479,19 @@ async function updateCodexTranscript(filePath, entry, stat) {
441
479
  // their multi-megabyte payloads when building the visible transcript.
442
480
  const head = line.slice(0, 400);
443
481
  if (!/"type"\s*:\s*"(?:response_item|event_msg)"/.test(head) ||
444
- !/"type"\s*:\s*"(?:message|agent_message|function_call|command_execution|custom_tool_call)"/.test(head)) continue;
482
+ !/"type"\s*:\s*"(?:message|agent_message|function_call|command_execution|custom_tool_call|task_started|task_complete|turn_aborted)"/.test(head)) continue;
445
483
  let d;
446
484
  try { d = JSON.parse(line); } catch { continue; }
447
485
 
486
+ if (d.type === 'event_msg' && d.payload) {
487
+ const p = d.payload;
488
+ if (p.type === 'task_started') entry.runtime = { state: 'pending', turnId: p.turn_id || null, startedAt: d.timestamp };
489
+ if ((p.type === 'task_complete' || p.type === 'turn_aborted') &&
490
+ (!entry.runtime?.turnId || !p.turn_id || entry.runtime.turnId === p.turn_id)) {
491
+ entry.runtime = { ...entry.runtime, state: p.type === 'task_complete' ? 'completed' : 'aborted', endedAt: d.timestamp };
492
+ }
493
+ }
494
+
448
495
  if (d.type === 'response_item' && d.payload) {
449
496
  const p = d.payload;
450
497
  if (p.type === 'message' && p.role === 'user') {
@@ -1713,7 +1760,10 @@ export async function loadSessionMessages(sessionId, agent, { claudeConfigDirs,
1713
1760
  if (a === 'codex') {
1714
1761
  const file = await findCodexSessionFile(sessionId, codexHome);
1715
1762
  if (!file) return null;
1716
- return { messages: await parseCodexMessages(file), context: null, agent: 'codex' };
1763
+ const messages = await parseCodexMessages(file);
1764
+ const runtime = await readCodexRuntime(file);
1765
+ const entry = codexTranscripts.get(file);
1766
+ return { messages, context: null, agent: 'codex', runtime, revision: `${entry?.ino}:${entry?.size}:${entry?.mtimeMs}` };
1717
1767
  }
1718
1768
  if (a === 'grok') {
1719
1769
  const dir = await findGrokSessionDir(sessionId, grokHome);
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);
@@ -1450,6 +1452,7 @@ export function createApp() {
1450
1452
  oldestIndex: page.oldestIndex,
1451
1453
  tail: page.tail,
1452
1454
  compact,
1455
+ runtime: bundle.runtime || null,
1453
1456
  };
1454
1457
  res.setHeader('X-Session-Agent', payload.agent);
1455
1458
  // A session can be appended while a turn is streaming. Returning a
@@ -1601,7 +1604,9 @@ export function createApp() {
1601
1604
  tail: req.query.tail === '1', maxChars: req.query.maxChars });
1602
1605
  if (!result) return res.status(404).json({ error: 'Session JSONL not found' });
1603
1606
  res.setHeader('Cache-Control', 'no-store');
1604
- res.json({ ...result, sessionId, running: isSessionBusy(sessionId) });
1607
+ const bundle = isSessionBusy(sessionId) ? null : await readSessionBundle(sessionId, agent);
1608
+ const runtime = bundle?.runtime || { running: isSessionBusy(sessionId), source: 'ui' };
1609
+ res.json({ ...result, sessionId, ...runtime });
1605
1610
  } catch (e) { res.status(500).json({ error: e.message }); }
1606
1611
  });
1607
1612
 
@@ -2046,11 +2051,16 @@ export function createApp() {
2046
2051
  if (cmd.cmd === 'session.subscribe') {
2047
2052
  client.sessionId = cmd.sessionId || client.sessionId || null;
2048
2053
  client.agent = cmd.agent || client.agent || null;
2049
- wireSend({
2050
- type: 'run-status',
2051
- sessionId: client.sessionId,
2052
- running: isSessionBusy(client.sessionId),
2053
- });
2054
+ const sid = client.sessionId;
2055
+ const agent = client.agent;
2056
+ if (isSessionBusy(sid)) {
2057
+ wireSend({ type: 'run-status', sessionId: sid, agent, running: true, source: 'ui' });
2058
+ } else {
2059
+ const bundle = await readSessionBundle(sid, agent).catch(() => null);
2060
+ if (client.sessionId !== sid || client.agent !== agent) return;
2061
+ wireSend({ type: 'run-status', sessionId: sid, agent,
2062
+ ...(bundle?.runtime || { running: false, source: 'ui' }) });
2063
+ }
2054
2064
  // UI-managed active runs already stream their events directly. For
2055
2065
  // everything else, tail the native persisted transcript so an agent
2056
2066
  // launched in an ordinary terminal appears live in this UI.
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  function transcriptFingerprint(bundle) {
13
+ if (bundle?.revision) return `${bundle.revision}:${JSON.stringify(bundle.runtime || null)}`;
13
14
  const messages = Array.isArray(bundle?.messages) ? bundle.messages : [];
14
15
  // Message parsing already normalizes the native formats. JSON preserves a
15
16
  // change to an in-progress/rewritten final message as well as append-only
@@ -49,6 +50,7 @@ export function createExternalSessionMirror({ readSession, emit, intervalMs = 75
49
50
  const changed = fingerprint !== entry.fingerprint;
50
51
  entry.fingerprint = fingerprint;
51
52
  if (changed || announce) {
53
+ if (bundle.runtime) emit(entry.sessionId, { type: 'run-status', sessionId: entry.sessionId, agent: bundle.agent || entry.agent, ...bundle.runtime });
52
54
  emit(entry.sessionId, {
53
55
  type: 'external-session-updated',
54
56
  sessionId: entry.sessionId,
@@ -0,0 +1,76 @@
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
+ const headers = sanitizeHeaders(Object.fromEntries(res.headers.entries()));
51
+ // Identity bodies retain their exact length (ordinary proxy replies recompute it).
52
+ if (!res.headers.get('content-encoding') || res.headers.get('content-encoding') === 'identity') {
53
+ const length = res.headers.get('content-length');
54
+ if (length !== null) headers['content-length'] = length;
55
+ }
56
+ await send({ type: 'http-res-start', status: res.status, headers });
57
+ for await (const value of res.body || []) {
58
+ // Each frame is small; sends wait for socket backpressure and share a rate cap.
59
+ for (let offset = 0; offset < value.byteLength; offset += 24 * 1024) {
60
+ const chunk = Buffer.from(value.subarray(offset, offset + 24 * 1024));
61
+ await budget.take(Math.ceil(chunk.length * 4 / 3) + 128, controller.signal);
62
+ await send({ type: 'http-chunk', data: chunk.toString('base64'), encoding: 'base64' });
63
+ }
64
+ }
65
+ await send({ type: 'http-end' });
66
+ } catch (err) {
67
+ if (ws.readyState === WebSocket.OPEN) await send({ type: 'http-error', message: err.message }).catch(() => {});
68
+ throw err;
69
+ }
70
+ } finally {
71
+ controller.abort();
72
+ ws?.close();
73
+ active--;
74
+ }
75
+ };
76
+ }