claude-simple 0.2.27 → 0.2.28

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/public/style.css CHANGED
@@ -1830,6 +1830,122 @@ html.settings-open #app { visibility: hidden; }
1830
1830
  .set-nav-item { white-space: nowrap; }
1831
1831
  }
1832
1832
 
1833
+ /* ── Personal notes (main pane, sidebar stays) ────────────────────────────── */
1834
+
1835
+ .notes-page {
1836
+ flex: 1;
1837
+ min-height: 0;
1838
+ display: flex;
1839
+ flex-direction: column;
1840
+ padding: 16px clamp(12px, 3vw, 32px) 24px;
1841
+ gap: 12px;
1842
+ overflow-y: auto;
1843
+ background: hsl(var(--b1));
1844
+ color: hsl(var(--bc));
1845
+ }
1846
+ .notes-page[hidden] { display: none !important; }
1847
+ .notes-count {
1848
+ margin: 4px 0 0;
1849
+ font-size: 12px;
1850
+ color: hsl(var(--bc) / .55);
1851
+ }
1852
+ .notes-header {
1853
+ display: flex;
1854
+ align-items: center;
1855
+ justify-content: space-between;
1856
+ gap: 16px;
1857
+ border-bottom: 1px solid hsl(var(--b3) / .35);
1858
+ padding-bottom: 12px;
1859
+ }
1860
+ .notes-header h2 {
1861
+ margin: 0;
1862
+ font-size: 1.4rem;
1863
+ letter-spacing: .02em;
1864
+ }
1865
+ .notes-header-actions { display: flex; gap: 8px; }
1866
+ .notes-add {
1867
+ display: flex;
1868
+ flex-direction: column;
1869
+ gap: 8px;
1870
+ padding: 12px;
1871
+ border-radius: 12px;
1872
+ background: hsl(var(--b2));
1873
+ border: 1px solid hsl(var(--b3) / .4);
1874
+ }
1875
+ .notes-add textarea,
1876
+ .notes-edit-text {
1877
+ width: 100%;
1878
+ font: inherit;
1879
+ background: hsl(var(--b1));
1880
+ color: inherit;
1881
+ border: 1px solid hsl(var(--b3) / .4);
1882
+ border-radius: 8px;
1883
+ padding: 10px 12px;
1884
+ resize: vertical;
1885
+ min-height: 80px;
1886
+ }
1887
+ .notes-form-actions {
1888
+ display: flex;
1889
+ gap: 8px;
1890
+ justify-content: flex-end;
1891
+ }
1892
+ .notes-list {
1893
+ list-style: none;
1894
+ margin: 0;
1895
+ padding: 0;
1896
+ display: flex;
1897
+ flex-direction: column;
1898
+ gap: 10px;
1899
+ }
1900
+ .notes-empty {
1901
+ padding: 28px;
1902
+ text-align: center;
1903
+ color: hsl(var(--bc) / .55);
1904
+ border: 1px dashed hsl(var(--b3) / .45);
1905
+ border-radius: 12px;
1906
+ }
1907
+ .notes-error { color: hsl(var(--er) / .85); border-color: hsl(var(--er) / .35); }
1908
+ .notes-item {
1909
+ background: hsl(var(--b2));
1910
+ border: 1px solid hsl(var(--b3) / .35);
1911
+ border-radius: 12px;
1912
+ padding: 14px 16px;
1913
+ display: flex;
1914
+ flex-direction: column;
1915
+ gap: 8px;
1916
+ }
1917
+ .notes-text {
1918
+ white-space: pre-wrap;
1919
+ word-break: break-word;
1920
+ font-size: .98rem;
1921
+ line-height: 1.55;
1922
+ }
1923
+ .notes-meta {
1924
+ font-size: .75rem;
1925
+ color: hsl(var(--bc) / .55);
1926
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1927
+ }
1928
+ .notes-item-foot {
1929
+ display: flex;
1930
+ align-items: center;
1931
+ gap: 8px;
1932
+ }
1933
+ .notes-item-actions {
1934
+ display: flex;
1935
+ gap: 4px;
1936
+ margin-left: auto;
1937
+ }
1938
+ .btn.danger {
1939
+ background: hsl(var(--er) / .15);
1940
+ color: hsl(var(--er));
1941
+ border: 1px solid hsl(var(--er) / .35);
1942
+ }
1943
+ .btn.primary {
1944
+ background: hsl(var(--p));
1945
+ color: hsl(var(--pc));
1946
+ border: 1px solid hsl(var(--p) / .85);
1947
+ }
1948
+
1833
1949
  /* Overlays must not eat keystrokes when hidden */
1834
1950
  #sidebar-overlay.hidden,
1835
1951
  .mermaid-zoom-overlay.hidden,
@@ -1847,3 +1963,43 @@ html.settings-open #app { visibility: hidden; }
1847
1963
  cursor: not-allowed;
1848
1964
  opacity: .78;
1849
1965
  }
1966
+
1967
+ .notes-add[hidden] { display: none; }
1968
+ .notes-error { color: #ef4444; }
1969
+
1970
+ .notes-item {
1971
+ background: rgba(255255255, .04);
1972
+ border: 1px solid rgba(255255255, .08);
1973
+ border-radius: 8px;
1974
+ padding: 12px;
1975
+ }
1976
+ .notes-text {
1977
+ white-space: pre-wrap;
1978
+ word-break: break-word;
1979
+ line-height: 1.55;
1980
+ }
1981
+ .notes-meta {
1982
+ margin-top: 8px;
1983
+ color: rgba(255255255, .4);
1984
+ font-size: 11px;
1985
+ display: flex;
1986
+ gap: 6px;
1987
+ }
1988
+ .notes-item-actions {
1989
+ margin-top: 8px;
1990
+ display: flex;
1991
+ gap: 6px;
1992
+ justify-content: flex-end;
1993
+ }
1994
+ .notes-edit-text {
1995
+ width: 100%;
1996
+ resize: vertical;
1997
+ min-height: 60px;
1998
+ background: transparent;
1999
+ color: inherit;
2000
+ border: 1px solid rgba(255255255, .08);
2001
+ border-radius: 6px;
2002
+ padding: 6px 8px;
2003
+ font-family: inherit;
2004
+ font-size: 14px;
2005
+ }
package/public/sw.js CHANGED
@@ -1,7 +1,7 @@
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-v27';
4
+ const CACHE = 'agent-ui-shell-v28';
5
5
  const PRECACHE = [
6
6
  '/',
7
7
  '/index.html',
@@ -388,8 +388,24 @@ function clientFor(options, cliPath) {
388
388
  return client;
389
389
  }
390
390
 
391
+ export function grokActivityStatus(update) {
392
+ const kind = update?.sessionUpdate;
393
+ if (kind === 'tool_call' || kind === 'tool_call_update') {
394
+ const name = update.title || update.kind || 'tool';
395
+ const st = update.status ? ` (${update.status})` : '';
396
+ return `Grok 工具: ${name}${st}`;
397
+ }
398
+ if (kind === 'agent_thought_chunk') return 'Grok 正在思考…';
399
+ if (kind === 'agent_message_chunk') return '';
400
+ return 'Grok ACP running…';
401
+ }
402
+
391
403
  function emitUpdate(update, send, state) {
392
404
  const kind = update?.sessionUpdate;
405
+ const activity = grokActivityStatus(update);
406
+ if (activity) {
407
+ send({ type: 'status', message: activity, agent: 'grok' });
408
+ }
393
409
  if (kind === 'agent_message_chunk' && update.content?.text) {
394
410
  if (state.thoughtOpen) {
395
411
  send({ type: 'thought_stream_end', agent: 'grok' });
@@ -496,6 +496,105 @@ async function forkCodexNativeSession(sourceSessionId, targetSessionId, codexHom
496
496
  return targetFile;
497
497
  }
498
498
 
499
+ function grokAssistantText(record) {
500
+ if (record?.type !== 'assistant') return '';
501
+ return extractGrokText(record.content).trim();
502
+ }
503
+
504
+ function grokUpdateKind(record) {
505
+ const update = record?.params?.update || record?.update || {};
506
+ return update.sessionUpdate || update.type || '';
507
+ }
508
+
509
+ function grokUpdateText(record) {
510
+ const update = record?.params?.update || record?.update || {};
511
+ return extractGrokText(update.content).trim();
512
+ }
513
+
514
+ /**
515
+ * Grok resumes from a session directory, not a synthetic chat transcript.
516
+ * Fork by cloning the native files and cutting both journals at the selected
517
+ * assistant reply so tool results and encrypted reasoning stay intact.
518
+ */
519
+ async function forkGrokNativeSession(sourceSessionId, targetSessionId, grokHome, assistantText, occurrence) {
520
+ const sourceDir = await findGrokSessionDir(sourceSessionId, grokHome);
521
+ if (!sourceDir) throw new Error('Grok source session was not found');
522
+
523
+ const sourceHistory = await fs.readFile(path.join(sourceDir, 'chat_history.jsonl'), 'utf8');
524
+ const historyOut = [];
525
+ let foundForkPoint = false;
526
+ let seenMatches = 0;
527
+ for (const line of sourceHistory.split(/\r?\n/)) {
528
+ if (!line.trim()) continue;
529
+ let record;
530
+ try { record = JSON.parse(line); } catch { continue; }
531
+ historyOut.push(JSON.stringify(replaceSessionId(record, sourceSessionId, targetSessionId)));
532
+ const text = grokAssistantText(record);
533
+ if (text && text === assistantText && ++seenMatches === occurrence) {
534
+ foundForkPoint = true;
535
+ break;
536
+ }
537
+ }
538
+ if (!foundForkPoint) {
539
+ throw new Error('Selected Grok assistant message was not found in the native transcript');
540
+ }
541
+
542
+ const targetDir = path.join(path.dirname(sourceDir), targetSessionId);
543
+ await fs.mkdir(targetDir, { recursive: true });
544
+ const histTmp = path.join(targetDir, `chat_history.jsonl.tmp-${crypto.randomUUID()}`);
545
+ await fs.writeFile(histTmp, `${historyOut.join('\n')}\n`, 'utf8');
546
+ await fs.rename(histTmp, path.join(targetDir, 'chat_history.jsonl'));
547
+
548
+ try {
549
+ const sourceUpdates = await fs.readFile(path.join(sourceDir, 'updates.jsonl'), 'utf8');
550
+ const updatesOut = [];
551
+ let acc = '';
552
+ let seenUpdateMatches = 0;
553
+ let matched = false;
554
+ for (const line of sourceUpdates.split(/\r?\n/)) {
555
+ if (!line.trim()) continue;
556
+ let record;
557
+ try { record = JSON.parse(line); } catch { continue; }
558
+ const kind = grokUpdateKind(record);
559
+ if (matched && (kind === 'user_message_chunk' || kind === 'agent_message_chunk')) break;
560
+ updatesOut.push(JSON.stringify(replaceSessionId(record, sourceSessionId, targetSessionId)));
561
+ if (kind === 'agent_message_chunk') {
562
+ acc = `${acc}${grokUpdateText(record)}`;
563
+ if (acc.trim() === assistantText && ++seenUpdateMatches === occurrence) matched = true;
564
+ } else if (kind === 'user_message_chunk' || kind === 'turn_completed') {
565
+ acc = '';
566
+ }
567
+ }
568
+ const updTmp = path.join(targetDir, `updates.jsonl.tmp-${crypto.randomUUID()}`);
569
+ await fs.writeFile(updTmp, updatesOut.length ? `${updatesOut.join('\n')}\n` : '', 'utf8');
570
+ await fs.rename(updTmp, path.join(targetDir, 'updates.jsonl'));
571
+ } catch {}
572
+
573
+ const copyNames = ['system_prompt.txt', 'prompt_context.json', 'resources_state.json', 'announcement_state.json', 'signals.json'];
574
+ for (const name of copyNames) {
575
+ try {
576
+ const raw = await fs.readFile(path.join(sourceDir, name), 'utf8');
577
+ let out = raw;
578
+ if (name.endsWith('.json')) {
579
+ try { out = JSON.stringify(replaceSessionId(JSON.parse(raw), sourceSessionId, targetSessionId), null, 2); }
580
+ catch { out = raw.replaceAll(sourceSessionId, targetSessionId); }
581
+ }
582
+ await fs.writeFile(path.join(targetDir, name), out, 'utf8');
583
+ } catch {}
584
+ }
585
+
586
+ try {
587
+ const summaryRaw = await fs.readFile(path.join(sourceDir, 'summary.json'), 'utf8');
588
+ const summary = replaceSessionId(JSON.parse(summaryRaw), sourceSessionId, targetSessionId);
589
+ if (summary.info && typeof summary.info === 'object') summary.info.id = targetSessionId;
590
+ else summary.info = { ...(summary.info || {}), id: targetSessionId };
591
+ summary.updated_at = new Date().toISOString();
592
+ await fs.writeFile(path.join(targetDir, 'summary.json'), JSON.stringify(summary, null, 2), 'utf8');
593
+ } catch {}
594
+
595
+ return targetDir;
596
+ }
597
+
499
598
  // ─── Grok ─────────────────────────────────────────────────────────────────────
500
599
 
501
600
  export async function scanGrokSessions(grokHome, filterCwd = null) {
@@ -1654,10 +1753,10 @@ export async function readSessionMemoryFile(sessionId, agent, fileId, opts) {
1654
1753
  }
1655
1754
 
1656
1755
  /** Return bounded raw JSONL for a particular agent session, for Meta Agent analysis. */
1657
- export async function loadSessionJsonl(sessionId, agent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }, { offset = 0, maxLines = 500 } = {}) {
1756
+ export async function loadSessionJsonl(sessionId, agent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }, { offset = 0, maxLines = 500, tail = false, maxChars = 24000 } = {}) {
1658
1757
  if (!agent) {
1659
1758
  for (const candidate of ['claude', 'codex', 'grok', 'gemini', 'cursor']) {
1660
- const found = await loadSessionJsonl(sessionId, candidate, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }, { offset, maxLines });
1759
+ const found = await loadSessionJsonl(sessionId, candidate, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }, { offset, maxLines, tail, maxChars });
1661
1760
  if (found) return found;
1662
1761
  }
1663
1762
  return null;
@@ -1673,20 +1772,35 @@ export async function loadSessionJsonl(sessionId, agent, { claudeConfigDirs, cod
1673
1772
  file = dir ? path.join(dir, 'chat_history.jsonl') : null;
1674
1773
  } else file = await findClaudeSessionFile(sessionId, claudeConfigDirs);
1675
1774
  if (!file) return null;
1676
- const start = Math.max(0, Number(offset) || 0);
1677
- const limit = Math.min(Math.max(1, Number(maxLines) || 500), 2000);
1775
+ const start = Math.max(0, Math.floor(Number(offset) || 0));
1776
+ const limit = Math.min(Math.max(1, Math.floor(Number(maxLines) || 100)), 500);
1777
+ const budget = Math.min(Math.max(1000, Number(maxChars) || 24000), 64000);
1678
1778
  const lines = [];
1679
- let n = 0;
1680
- let hasMore = false;
1779
+ let n = 0, chars = 0, hasMore = false, clipped = false;
1681
1780
  const stream = fsSync.createReadStream(file, { encoding: 'utf8' });
1682
1781
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
1683
- for await (const line of rl) {
1684
- if (n++ < start) continue;
1685
- if (lines.length >= limit) { hasMore = true; rl.close(); stream.destroy(); break; }
1686
- lines.push(line);
1687
- }
1782
+ try {
1783
+ for await (const line of rl) {
1784
+ const index = n++;
1785
+ if (!tail && index < start) continue;
1786
+ if (!tail && (lines.length >= limit || chars >= budget)) { hasMore = true; break; }
1787
+ // Bound huge tool output rows as well as total response size. Clipping
1788
+ // is explicit: a clipped line must not be mistaken for complete JSON.
1789
+ const cap = tail ? budget : budget - chars;
1790
+ const text = line.slice(0, cap);
1791
+ if (text.length < line.length) clipped = true;
1792
+ lines.push(text); chars += text.length;
1793
+ if (tail) while (lines.length > limit || (chars > budget && lines.length > 1)) {
1794
+ chars -= lines.shift().length;
1795
+ }
1796
+ }
1797
+ } finally { rl.close(); stream.destroy(); }
1688
1798
  const stat = await fs.stat(file).catch(() => null);
1689
- return { agent: a, fileName: path.basename(file), offset: start, lines, nextOffset: start + lines.length, truncated: hasMore, size: stat?.size || 0 };
1799
+ const actualOffset = tail ? Math.max(0, n - lines.length) : start;
1800
+ return { agent: a, fileName: path.basename(file), offset: actualOffset, lines,
1801
+ nextOffset: actualOffset + lines.length, truncated: hasMore, clipped,
1802
+ tail, size: stat?.size || 0, updatedAt: stat?.mtimeMs || null };
1803
+
1690
1804
  }
1691
1805
 
1692
1806
  export async function getSessionMeta(sessionId, agent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }) {
@@ -1796,6 +1910,17 @@ export async function convertSession(sourceSessionId, sourceAgent, targetAgent,
1796
1910
  } catch {}
1797
1911
  return { targetSessionId };
1798
1912
  }
1913
+
1914
+ if (ta === 'grok' && sourceAgent.toLowerCase() === 'grok' && Number.isInteger(upTo)) {
1915
+ const selected = messages[messages.length - 1];
1916
+ if (selected?.role !== 'assistant' || !selected.content?.trim()) {
1917
+ throw new Error('A Grok fork must end at a persisted assistant message');
1918
+ }
1919
+ const occurrence = messages.filter(message => message.role === 'assistant'
1920
+ && message.content?.trim() === selected.content.trim()).length;
1921
+ await forkGrokNativeSession(sourceSessionId, targetSessionId, grokHome, selected.content.trim(), occurrence);
1922
+ return { targetSessionId };
1923
+ }
1799
1924
 
1800
1925
  if (ta === 'claude') {
1801
1926
  const configDir = claudeConfigDirs?.[0] || path.join(os.homedir(), '.claude');
package/src/app.js CHANGED
@@ -37,6 +37,7 @@ import { createExternalSessionMirror } from './runtime/external-session-sync.js'
37
37
  import { resolveApproval, cancelApprovalsForSession } from './runtime/approvals.js';
38
38
  import { createMcpHttpHandler, createMcpService } from './mcp.js';
39
39
  import { createCollab, roleAtLeast, proxyToLocalPort, machineId as localMachineId } from './collab.js';
40
+ import { parseHistoryQuery, sliceHistoryMessages } from './session-history.js';
40
41
  import {
41
42
  CLAUDE_ENV_KEYS,
42
43
  loadAgentEnvDoc,
@@ -1376,11 +1377,9 @@ export function createApp() {
1376
1377
  const { sessionId } = req.params;
1377
1378
  if (!/^[0-9a-f-]{36}$/i.test(sessionId)) return res.status(400).json({ error: 'Invalid session id' });
1378
1379
  const preferred = typeof req.query.agent === 'string' ? req.query.agent : null;
1379
- // tail=0 → full history (default). Positive = last N messages only.
1380
- let tail = parseInt(req.query.tail ?? '0', 10);
1381
- if (Number.isNaN(tail)) tail = 0;
1382
- if (tail < 0) tail = 0;
1383
- if (tail > 50_000) tail = 50_000;
1380
+ // tail=0 → full history (default). Positive = last N. before = exclusive
1381
+ // end index for older pages (scroll-up).
1382
+ const { tail, before } = parseHistoryQuery(req.query);
1384
1383
  const compact = req.query.compact !== '0' && req.query.compact !== 'false';
1385
1384
  const opts = {
1386
1385
  claudeConfigDirs: CLAUDE_CONFIG_DIRS,
@@ -1403,20 +1402,16 @@ export function createApp() {
1403
1402
  let messages = Array.isArray(bundle)
1404
1403
  ? bundle
1405
1404
  : (bundle.messages || []);
1406
- const total = messages.length;
1407
- let truncated = false;
1408
- if (tail > 0 && messages.length > tail) {
1409
- messages = messages.slice(-tail);
1410
- truncated = true;
1411
- }
1412
- if (compact) messages = compactHistoryMessages(messages);
1405
+ const page = sliceHistoryMessages(messages, { tail, before });
1406
+ messages = compact ? compactHistoryMessages(page.messages) : page.messages;
1413
1407
  const payload = {
1414
1408
  messages,
1415
1409
  context: Array.isArray(bundle) ? null : (bundle.context || null),
1416
1410
  agent: Array.isArray(bundle) ? (preferred || 'claude') : (bundle.agent || preferred || 'claude'),
1417
- total,
1418
- truncated,
1419
- tail: truncated ? tail : total,
1411
+ total: page.total,
1412
+ truncated: page.truncated,
1413
+ oldestIndex: page.oldestIndex,
1414
+ tail: page.tail,
1420
1415
  compact,
1421
1416
  };
1422
1417
  res.setHeader('X-Session-Agent', payload.agent);
@@ -1552,6 +1547,27 @@ export function createApp() {
1552
1547
  }
1553
1548
  });
1554
1549
 
1550
+ app.get('/api/sessions/:sessionId/jsonl', authMiddleware, async (req, res) => {
1551
+ try {
1552
+ const { sessionId } = req.params;
1553
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
1554
+ return res.status(400).json({ error: 'Invalid session id' });
1555
+ }
1556
+ const agent = typeof req.query.agent === 'string' ? req.query.agent : null;
1557
+ if (agent && !['claude', 'codex', 'grok', 'gemini', 'cursor'].includes(agent)) {
1558
+ return res.status(400).json({ error: 'Invalid agent' });
1559
+ }
1560
+ const result = await loadSessionJsonl(sessionId, agent, {
1561
+ claudeConfigDirs: CLAUDE_CONFIG_DIRS, codexHome: CODEX_HOME,
1562
+ grokHome: GROK_HOME, geminiHome: GEMINI_HOME, cursorHome: CURSOR_HOME,
1563
+ }, { offset: req.query.offset, maxLines: req.query.limit,
1564
+ tail: req.query.tail === '1', maxChars: req.query.maxChars });
1565
+ if (!result) return res.status(404).json({ error: 'Session JSONL not found' });
1566
+ res.setHeader('Cache-Control', 'no-store');
1567
+ res.json({ ...result, sessionId, running: isSessionBusy(sessionId) });
1568
+ } catch (e) { res.status(500).json({ error: e.message }); }
1569
+ });
1570
+
1555
1571
  app.get('/api/sessions/:sessionId/memory/file', authMiddleware, async (req, res) => {
1556
1572
  try {
1557
1573
  const { sessionId } = req.params;
@@ -0,0 +1,31 @@
1
+ /** Slice a loaded transcript for mobile/hub transfers. */
2
+
3
+ export function parseHistoryQuery(query = {}) {
4
+ let tail = parseInt(query.tail ?? '0', 10);
5
+ if (Number.isNaN(tail) || tail < 0) tail = 0;
6
+ if (tail > 50_000) tail = 50_000;
7
+ let before = parseInt(query.before ?? '', 10);
8
+ if (Number.isNaN(before) || before < 0) before = null;
9
+ return { tail, before };
10
+ }
11
+
12
+ /**
13
+ * @param {unknown[]} messages
14
+ * @param {{ tail?: number, before?: number|null }} opts
15
+ */
16
+ export function sliceHistoryMessages(messages, { tail = 0, before = null } = {}) {
17
+ const all = Array.isArray(messages) ? messages : [];
18
+ const total = all.length;
19
+ let end = total;
20
+ if (before != null) end = Math.min(before, total);
21
+ if (end < 0) end = 0;
22
+ let start = 0;
23
+ if (tail > 0 && end - start > tail) start = end - tail;
24
+ return {
25
+ messages: all.slice(start, end),
26
+ total,
27
+ truncated: start > 0,
28
+ oldestIndex: start,
29
+ tail: end - start,
30
+ };
31
+ }