claude-home 1.4.8 → 1.5.9
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/package.json +1 -1
- package/public/index.html +405 -56
- package/server.js +77 -2
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -740,6 +740,90 @@
|
|
|
740
740
|
font-variant-numeric: tabular-nums;
|
|
741
741
|
}
|
|
742
742
|
|
|
743
|
+
/* ── Projects ─────────────────────────────────────────── */
|
|
744
|
+
.project-grid {
|
|
745
|
+
display: grid;
|
|
746
|
+
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
|
747
|
+
gap: 12px;
|
|
748
|
+
padding: 20px;
|
|
749
|
+
align-content: start;
|
|
750
|
+
}
|
|
751
|
+
.project-card {
|
|
752
|
+
background: var(--white);
|
|
753
|
+
border: 1px solid var(--rule);
|
|
754
|
+
border-radius: 6px;
|
|
755
|
+
padding: 16px 18px;
|
|
756
|
+
cursor: pointer;
|
|
757
|
+
transition: border-color 0.12s, box-shadow 0.12s;
|
|
758
|
+
}
|
|
759
|
+
.project-card:hover {
|
|
760
|
+
border-color: var(--rule-2);
|
|
761
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
|
762
|
+
}
|
|
763
|
+
.project-card-name {
|
|
764
|
+
font-size: 14px;
|
|
765
|
+
font-weight: 600;
|
|
766
|
+
overflow: hidden;
|
|
767
|
+
text-overflow: ellipsis;
|
|
768
|
+
white-space: nowrap;
|
|
769
|
+
margin-bottom: 2px;
|
|
770
|
+
}
|
|
771
|
+
.project-card-path {
|
|
772
|
+
font-size: 10.5px;
|
|
773
|
+
color: var(--ink-3);
|
|
774
|
+
overflow: hidden;
|
|
775
|
+
text-overflow: ellipsis;
|
|
776
|
+
white-space: nowrap;
|
|
777
|
+
margin-bottom: 12px;
|
|
778
|
+
font-family: 'SF Mono','Fira Code',monospace;
|
|
779
|
+
}
|
|
780
|
+
.project-card-stats {
|
|
781
|
+
display: flex;
|
|
782
|
+
gap: 16px;
|
|
783
|
+
flex-wrap: wrap;
|
|
784
|
+
}
|
|
785
|
+
.project-stat { display: flex; flex-direction: column; gap: 1px; }
|
|
786
|
+
.project-stat-val { font-size: 17px; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.1; }
|
|
787
|
+
.project-stat-label { font-size: 10px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; }
|
|
788
|
+
.project-card-footer {
|
|
789
|
+
display: flex;
|
|
790
|
+
align-items: center;
|
|
791
|
+
gap: 8px;
|
|
792
|
+
margin-top: 12px;
|
|
793
|
+
padding-top: 10px;
|
|
794
|
+
border-top: 1px solid var(--rule);
|
|
795
|
+
flex-wrap: wrap;
|
|
796
|
+
}
|
|
797
|
+
.proj-badge {
|
|
798
|
+
font-size: 10px;
|
|
799
|
+
padding: 2px 7px;
|
|
800
|
+
border-radius: 3px;
|
|
801
|
+
background: var(--canvas);
|
|
802
|
+
color: var(--ink-3);
|
|
803
|
+
font-weight: 500;
|
|
804
|
+
}
|
|
805
|
+
.proj-badge.missing { background: #3a1a1a; color: var(--red); }
|
|
806
|
+
.proj-badge.memory { background: var(--canvas); color: var(--ink-2); }
|
|
807
|
+
.project-detail-header {
|
|
808
|
+
display: flex;
|
|
809
|
+
align-items: center;
|
|
810
|
+
gap: 12px;
|
|
811
|
+
padding: 14px 20px;
|
|
812
|
+
border-bottom: 1px solid var(--rule);
|
|
813
|
+
min-height: 52px;
|
|
814
|
+
}
|
|
815
|
+
.project-detail-body {
|
|
816
|
+
flex: 1;
|
|
817
|
+
display: flex;
|
|
818
|
+
flex-direction: column;
|
|
819
|
+
overflow: hidden;
|
|
820
|
+
}
|
|
821
|
+
.proj-md-content {
|
|
822
|
+
flex: 1;
|
|
823
|
+
overflow-y: auto;
|
|
824
|
+
padding: 20px;
|
|
825
|
+
}
|
|
826
|
+
|
|
743
827
|
/* ── Memory ───────────────────────────────────────────── */
|
|
744
828
|
.memory-layout {
|
|
745
829
|
display: flex;
|
|
@@ -1573,35 +1657,6 @@
|
|
|
1573
1657
|
<span style="color:var(--ink-3)">CO₂</span>
|
|
1574
1658
|
<span x-text="fmtCarbon(insights.totalCarbon) || '—'"></span>
|
|
1575
1659
|
</div>
|
|
1576
|
-
<template x-if="insights.totalCarbon > 100">
|
|
1577
|
-
<div style="margin-top:6px;display:flex;flex-direction:column;gap:3px">
|
|
1578
|
-
<!-- Car -->
|
|
1579
|
-
<div style="font-size:10px;color:var(--ink-3);display:flex;align-items:center;gap:5px">
|
|
1580
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M5 17H3v-5l2-5h14l2 5v5h-2"/><circle cx="7.5" cy="17.5" r="1.5"/><circle cx="16.5" cy="17.5" r="1.5"/><path d="M5 12h14"/></svg>
|
|
1581
|
-
<span x-text="carbonEquivs(insights.totalCarbon)?.car"></span>
|
|
1582
|
-
</div>
|
|
1583
|
-
<!-- Plane -->
|
|
1584
|
-
<div style="font-size:10px;color:var(--ink-3);display:flex;align-items:center;gap:5px">
|
|
1585
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5z"/></svg>
|
|
1586
|
-
<span x-text="carbonEquivs(insights.totalCarbon)?.flights"></span>
|
|
1587
|
-
</div>
|
|
1588
|
-
<!-- Tree -->
|
|
1589
|
-
<div style="font-size:10px;color:var(--ink-3);display:flex;align-items:center;gap:5px">
|
|
1590
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M12 22v-7"/><path d="M9 15l3-3 3 3"/><path d="M7 12l5-5 5 5"/><path d="M5 9l7-7 7 7"/></svg>
|
|
1591
|
-
<span x-text="carbonEquivs(insights.totalCarbon)?.trees"></span>
|
|
1592
|
-
</div>
|
|
1593
|
-
<!-- Burger / Food -->
|
|
1594
|
-
<div style="font-size:10px;color:var(--ink-3);display:flex;align-items:center;gap:5px">
|
|
1595
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M4 8h16"/><path d="M4 12h16"/><path d="M4 16h16"/><path d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/></svg>
|
|
1596
|
-
<span x-text="carbonEquivs(insights.totalCarbon)?.burgers"></span>
|
|
1597
|
-
</div>
|
|
1598
|
-
<!-- Home / Electricity -->
|
|
1599
|
-
<div style="font-size:10px;color:var(--ink-3);display:flex;align-items:center;gap:5px">
|
|
1600
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M3 9.5L12 3l9 6.5V20a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/><path d="M9 21V12h6v9"/></svg>
|
|
1601
|
-
<span x-text="carbonEquivs(insights.totalCarbon)?.electricity"></span>
|
|
1602
|
-
</div>
|
|
1603
|
-
</div>
|
|
1604
|
-
</template>
|
|
1605
1660
|
</div>
|
|
1606
1661
|
</template>
|
|
1607
1662
|
<!-- Version + last session -->
|
|
@@ -1614,12 +1669,174 @@
|
|
|
1614
1669
|
Last: <span x-text="formatDate(sessions[0]?.modified)"></span>
|
|
1615
1670
|
</div>
|
|
1616
1671
|
</template>
|
|
1672
|
+
<div class="sidebar-footer-row" style="color:var(--ink-3)">
|
|
1673
|
+
Created by <a href="https://zenekezene.com/" target="_blank" style="color:var(--ink-2);text-decoration:none;" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">Zenekezene</a> with wine
|
|
1674
|
+
</div>
|
|
1617
1675
|
</div>
|
|
1618
1676
|
</aside>
|
|
1619
1677
|
|
|
1620
1678
|
<!-- ── Main ── -->
|
|
1621
1679
|
<div class="main">
|
|
1622
1680
|
|
|
1681
|
+
<!-- Project detail -->
|
|
1682
|
+
<template x-if="view === 'projects' && selectedProject && !selectedSession">
|
|
1683
|
+
<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">
|
|
1684
|
+
<!-- Header -->
|
|
1685
|
+
<div class="project-detail-header">
|
|
1686
|
+
<button class="btn btn-outline btn-sm" @click="selectedProject=null;view='dashboard'" style="flex-shrink:0">← Dashboard</button>
|
|
1687
|
+
<div style="flex:1;min-width:0">
|
|
1688
|
+
<div style="font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" x-text="shortProjectName(selectedProject.projectPath) || selectedProject.dirName"></div>
|
|
1689
|
+
<div style="font-size:10.5px;color:var(--ink-3);font-family:'SF Mono','Fira Code',monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" x-text="selectedProject.projectPath"></div>
|
|
1690
|
+
</div>
|
|
1691
|
+
<!-- Stats chips -->
|
|
1692
|
+
<div style="display:flex;gap:10px;align-items:center;flex-shrink:0">
|
|
1693
|
+
<span style="font-size:11.5px;color:var(--ink-3)"><b x-text="selectedProject.sessionCount"></b> sessions</span>
|
|
1694
|
+
<template x-if="selectedProject.memoryCount > 0">
|
|
1695
|
+
<span style="font-size:11.5px;color:var(--ink-3)"><b x-text="selectedProject.memoryCount"></b> memories</span>
|
|
1696
|
+
</template>
|
|
1697
|
+
<template x-if="projectCost(selectedProject.dirName) !== null">
|
|
1698
|
+
<span style="font-size:11.5px;color:var(--ink-3)"><b x-text="fmtCost(projectCost(selectedProject.dirName))"></b> cost</span>
|
|
1699
|
+
</template>
|
|
1700
|
+
<template x-if="!selectedProject.diskExists">
|
|
1701
|
+
<span class="proj-badge missing">directory missing</span>
|
|
1702
|
+
</template>
|
|
1703
|
+
</div>
|
|
1704
|
+
</div>
|
|
1705
|
+
|
|
1706
|
+
<!-- Tabs -->
|
|
1707
|
+
<div class="tools-tabs" style="border-bottom:1px solid var(--rule)">
|
|
1708
|
+
<div class="tools-tab" :class="{active: projectTab==='sessions'}" @click="projectTab='sessions'">Sessions</div>
|
|
1709
|
+
<div class="tools-tab" :class="{active: projectTab==='memory'}" @click="projectTab='memory'">Memory <template x-if="projectMemory.length>0"><span class="nav-count" x-text="projectMemory.length"></span></template></div>
|
|
1710
|
+
<div class="tools-tab" :class="{active: projectTab==='claudemd'}" @click="projectTab='claudemd'">CLAUDE.md</div>
|
|
1711
|
+
</div>
|
|
1712
|
+
|
|
1713
|
+
<!-- Tab: Sessions -->
|
|
1714
|
+
<template x-if="projectTab === 'sessions'">
|
|
1715
|
+
<div style="flex:1;overflow-y:auto;padding-top:6px;">
|
|
1716
|
+
<template x-if="projectSessionsLoading"><div class="loading"><div class="spinner"></div> Loading…</div></template>
|
|
1717
|
+
<template x-if="!projectSessionsLoading && projectSessions.length === 0">
|
|
1718
|
+
<div class="empty"><div class="empty-mark"></div>No sessions</div>
|
|
1719
|
+
</template>
|
|
1720
|
+
<template x-if="!projectSessionsLoading && projectSessions.length > 0">
|
|
1721
|
+
<div class="sessions-list" style="padding:0 20px">
|
|
1722
|
+
<template x-for="s in projectSessions" :key="s.sessionId">
|
|
1723
|
+
<div class="session-row" @click="view='sessions';openSession(s)">
|
|
1724
|
+
<div class="session-body">
|
|
1725
|
+
<div class="session-prompt" x-text="s.firstPrompt || '(no prompt)'"></div>
|
|
1726
|
+
<div class="session-lower">
|
|
1727
|
+
<template x-if="s.gitBranch">
|
|
1728
|
+
<div class="session-branch">
|
|
1729
|
+
<div class="branch-square" :style="'background:' + branchColor(s.gitBranch)"></div>
|
|
1730
|
+
<span x-text="s.gitBranch"></span>
|
|
1731
|
+
</div>
|
|
1732
|
+
</template>
|
|
1733
|
+
<span class="session-msgs" x-text="s.messageCount + ' msgs'"></span>
|
|
1734
|
+
<span class="session-msgs" x-text="formatDate(s.modified)"></span>
|
|
1735
|
+
<template x-if="sessionCosts[s.sessionId]">
|
|
1736
|
+
<span class="session-msgs" style="padding-left:8px;border-left:1px solid var(--rule);font-family:'SF Mono','Fira Code',monospace" x-text="fmtCost(sessionCosts[s.sessionId])"></span>
|
|
1737
|
+
</template>
|
|
1738
|
+
</div>
|
|
1739
|
+
</div>
|
|
1740
|
+
<div class="session-right">
|
|
1741
|
+
<button class="session-resume-btn" :disabled="!s.resumable" :title="!s.resumable ? 'Session file deleted' : 'Copy resume command'" @click.stop="s.resumable && resumeSession(s.sessionId, 'proj-resume-'+s.sessionId, s.projectPath)" :id="'proj-resume-'+s.sessionId">Resume →</button>
|
|
1742
|
+
</div>
|
|
1743
|
+
</div>
|
|
1744
|
+
</template>
|
|
1745
|
+
</div>
|
|
1746
|
+
</template>
|
|
1747
|
+
</div>
|
|
1748
|
+
</template>
|
|
1749
|
+
|
|
1750
|
+
<!-- Tab: Memory -->
|
|
1751
|
+
<template x-if="projectTab === 'memory'">
|
|
1752
|
+
<div style="flex:1;overflow:hidden;display:flex;">
|
|
1753
|
+
<template x-if="projectMemoryLoading"><div class="loading"><div class="spinner"></div> Loading…</div></template>
|
|
1754
|
+
<template x-if="!projectMemoryLoading && projectMemory.length === 0">
|
|
1755
|
+
<div class="empty"><div class="empty-mark"></div>No memory files for this project</div>
|
|
1756
|
+
</template>
|
|
1757
|
+
<template x-if="!projectMemoryLoading && projectMemory.length > 0">
|
|
1758
|
+
<div style="display:flex;width:100%;overflow:hidden;">
|
|
1759
|
+
<div class="memory-sidebar" :style="'width:'+sidebarW+'px'">
|
|
1760
|
+
<template x-for="f in projectMemory" :key="f.filename">
|
|
1761
|
+
<div class="memory-row" :class="{active: projectMemorySelected?.filename===f.filename}"
|
|
1762
|
+
@click="projectMemorySelected=f">
|
|
1763
|
+
<div class="memory-row-name" x-text="f.name || f.filename"></div>
|
|
1764
|
+
<div class="memory-row-desc" x-text="f.description || f.filename"></div>
|
|
1765
|
+
</div>
|
|
1766
|
+
</template>
|
|
1767
|
+
</div>
|
|
1768
|
+
<div class="resize-handle" @mousedown.prevent="startResize($event)"></div>
|
|
1769
|
+
<div class="memory-detail">
|
|
1770
|
+
<template x-if="!projectMemorySelected">
|
|
1771
|
+
<div class="memory-empty"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M5 12h14M12 5l7 7-7 7"/></svg>Select a memory file</div>
|
|
1772
|
+
</template>
|
|
1773
|
+
<template x-if="projectMemorySelected">
|
|
1774
|
+
<div>
|
|
1775
|
+
<div class="memory-detail-title" x-text="projectMemorySelected.name || projectMemorySelected.filename"></div>
|
|
1776
|
+
<div class="memory-detail-meta">
|
|
1777
|
+
<span :class="'type-pill type-'+(projectMemorySelected.type||'unknown')" x-text="projectMemorySelected.type||'unknown'"></span>
|
|
1778
|
+
<span class="memory-filename" x-text="projectMemorySelected.filename"></span>
|
|
1779
|
+
</div>
|
|
1780
|
+
<template x-if="projectMemorySelected.description"><div class="memory-desc-intro" x-text="projectMemorySelected.description"></div></template>
|
|
1781
|
+
<div class="memory-content md-content" x-html="renderMd(projectMemorySelected.content)"></div>
|
|
1782
|
+
</div>
|
|
1783
|
+
</template>
|
|
1784
|
+
</div>
|
|
1785
|
+
</div>
|
|
1786
|
+
</template>
|
|
1787
|
+
</div>
|
|
1788
|
+
</template>
|
|
1789
|
+
|
|
1790
|
+
<!-- Tab: CLAUDE.md -->
|
|
1791
|
+
<template x-if="projectTab === 'claudemd'">
|
|
1792
|
+
<div style="flex:1;overflow:hidden;display:flex;flex-direction:column;">
|
|
1793
|
+
<template x-if="projectClaudeMdLoading"><div class="loading"><div class="spinner"></div> Loading…</div></template>
|
|
1794
|
+
<template x-if="!projectClaudeMdLoading && projectClaudeMd">
|
|
1795
|
+
<div style="flex:1;overflow:hidden;display:flex;flex-direction:column;">
|
|
1796
|
+
<!-- File selector + actions -->
|
|
1797
|
+
<div style="display:flex;align-items:center;gap:8px;padding:8px 20px;border-bottom:1px solid var(--rule);flex-wrap:wrap">
|
|
1798
|
+
<template x-if="projectClaudeMd.files.length === 0">
|
|
1799
|
+
<span style="font-size:12px;color:var(--ink-3)">No CLAUDE.md found in this project</span>
|
|
1800
|
+
</template>
|
|
1801
|
+
<template x-if="projectClaudeMd.files.length > 0">
|
|
1802
|
+
<select class="filter-select" x-model.number="projectClaudeMdEdit.fileIdx" @change="projectClaudeMdEdit.active=false;projectClaudeMdEdit.msg=''">
|
|
1803
|
+
<template x-for="(f, i) in projectClaudeMd.files" :key="f.filePath">
|
|
1804
|
+
<option :value="i" x-text="f.label"></option>
|
|
1805
|
+
</template>
|
|
1806
|
+
</select>
|
|
1807
|
+
</template>
|
|
1808
|
+
<div style="margin-left:auto;display:flex;gap:8px;align-items:center">
|
|
1809
|
+
<template x-if="projectClaudeMdEdit.msg">
|
|
1810
|
+
<span :class="projectClaudeMdEdit.msg==='Saved'?'save-msg':'err-msg'" x-text="projectClaudeMdEdit.msg"></span>
|
|
1811
|
+
</template>
|
|
1812
|
+
<template x-if="projectClaudeMd.files.length > 0 && !projectClaudeMdEdit.active">
|
|
1813
|
+
<button class="btn btn-outline btn-sm" @click="projectClaudeMdEdit.active=true;projectClaudeMdEdit.draft=projectClaudeMd.files[projectClaudeMdEdit.fileIdx]?.content||'';projectClaudeMdEdit.msg=''">Edit</button>
|
|
1814
|
+
</template>
|
|
1815
|
+
<template x-if="projectClaudeMdEdit.active">
|
|
1816
|
+
<button class="btn btn-outline btn-sm" @click="projectClaudeMdEdit.active=false">Cancel</button>
|
|
1817
|
+
</template>
|
|
1818
|
+
<template x-if="projectClaudeMdEdit.active">
|
|
1819
|
+
<button class="btn btn-primary btn-sm" @click="saveProjectClaudeMd()" :disabled="projectClaudeMdEdit.saving" x-text="projectClaudeMdEdit.saving?'Saving…':'Save'"></button>
|
|
1820
|
+
</template>
|
|
1821
|
+
</div>
|
|
1822
|
+
</div>
|
|
1823
|
+
<!-- Content -->
|
|
1824
|
+
<div style="flex:1;overflow:hidden;">
|
|
1825
|
+
<template x-if="projectClaudeMd.files.length > 0 && !projectClaudeMdEdit.active">
|
|
1826
|
+
<div class="proj-md-content md-content" x-html="renderMd(projectClaudeMd.files[projectClaudeMdEdit.fileIdx]?.content||'')"></div>
|
|
1827
|
+
</template>
|
|
1828
|
+
<template x-if="projectClaudeMdEdit.active">
|
|
1829
|
+
<textarea class="edit-textarea" style="width:100%;height:100%;resize:none;border:none;outline:none;padding:20px;font-family:'SF Mono','Fira Code',monospace;font-size:12.5px;line-height:1.7;background:var(--white);box-sizing:border-box" x-model="projectClaudeMdEdit.draft"></textarea>
|
|
1830
|
+
</template>
|
|
1831
|
+
</div>
|
|
1832
|
+
</div>
|
|
1833
|
+
</template>
|
|
1834
|
+
</div>
|
|
1835
|
+
</template>
|
|
1836
|
+
|
|
1837
|
+
</div>
|
|
1838
|
+
</template>
|
|
1839
|
+
|
|
1623
1840
|
<!-- Dashboard (home) — disabled -->
|
|
1624
1841
|
<template x-if="view === 'dashboard' && !selectedSession">
|
|
1625
1842
|
<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">
|
|
@@ -1628,7 +1845,10 @@
|
|
|
1628
1845
|
<span style="font-size:11px;color:var(--ink-3)" x-text="formatDate(new Date().toISOString())"></span>
|
|
1629
1846
|
</div>
|
|
1630
1847
|
<div class="content">
|
|
1631
|
-
<div style="max-width:
|
|
1848
|
+
<div style="max-width:1200px;display:flex;gap:20px;align-items:flex-start">
|
|
1849
|
+
|
|
1850
|
+
<!-- Left column: all dashboard content -->
|
|
1851
|
+
<div style="flex:1;min-width:0">
|
|
1632
1852
|
|
|
1633
1853
|
<!-- Row 1: Stats -->
|
|
1634
1854
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px">
|
|
@@ -1638,12 +1858,42 @@
|
|
|
1638
1858
|
<div class="db-card-sub" x-text="insights ? fmtTokensM(insights.totalTokens.output) + ' output · ' + fmtTokensM(insights.totalTokens.input) + ' input' : ''"></div>
|
|
1639
1859
|
</div>
|
|
1640
1860
|
<div class="db-card">
|
|
1641
|
-
<div class="db-card-label"
|
|
1861
|
+
<div class="db-card-label" style="display:flex;align-items:center;gap:5px">
|
|
1862
|
+
Total cost
|
|
1863
|
+
<span class="tooltip-wrap" @click.stop>
|
|
1864
|
+
<i class="tooltip-icon">i</i>
|
|
1865
|
+
<span class="tooltip-box">
|
|
1866
|
+
<strong>Cost calculation</strong><br><br>
|
|
1867
|
+
Based on Anthropic's published API pricing (USD per million tokens):<br><br>
|
|
1868
|
+
<table style="border-collapse:collapse;width:100%;font-size:11px">
|
|
1869
|
+
<tr style="color:var(--ink-3)"><td></td><td style="text-align:right;padding-right:8px">Input</td><td style="text-align:right;padding-right:8px">Output</td><td style="text-align:right;padding-right:8px">Cache write</td><td style="text-align:right">Cache read</td></tr>
|
|
1870
|
+
<tr><td style="padding-right:8px">Opus</td><td style="text-align:right;padding-right:8px">$15</td><td style="text-align:right;padding-right:8px">$75</td><td style="text-align:right;padding-right:8px">$18.75</td><td style="text-align:right">$1.50</td></tr>
|
|
1871
|
+
<tr><td style="padding-right:8px">Sonnet</td><td style="text-align:right;padding-right:8px">$3</td><td style="text-align:right;padding-right:8px">$15</td><td style="text-align:right;padding-right:8px">$3.75</td><td style="text-align:right">$0.30</td></tr>
|
|
1872
|
+
<tr><td style="padding-right:8px">Haiku</td><td style="text-align:right;padding-right:8px">$0.80</td><td style="text-align:right;padding-right:8px">$4</td><td style="text-align:right;padding-right:8px">$1.00</td><td style="text-align:right">$0.08</td></tr>
|
|
1873
|
+
</table><br>
|
|
1874
|
+
Cost is summed across all sessions by reading token usage from each JSONL file. Sessions with no token data are excluded.
|
|
1875
|
+
</span>
|
|
1876
|
+
</span>
|
|
1877
|
+
</div>
|
|
1642
1878
|
<div class="db-card-value" x-text="insights ? fmtCost(insights.totalCost) : '—'"></div>
|
|
1643
1879
|
<div class="db-card-sub">all time</div>
|
|
1644
1880
|
</div>
|
|
1645
1881
|
<div class="db-card">
|
|
1646
|
-
<div class="db-card-label"
|
|
1882
|
+
<div class="db-card-label" style="display:flex;align-items:center;gap:5px">
|
|
1883
|
+
Cache savings
|
|
1884
|
+
<span class="tooltip-wrap" @click.stop>
|
|
1885
|
+
<i class="tooltip-icon">i</i>
|
|
1886
|
+
<span class="tooltip-box">
|
|
1887
|
+
<strong>How cache savings are calculated</strong><br><br>
|
|
1888
|
+
When Claude reuses a prompt prefix it has seen before, those tokens are billed at the cheaper <em>cache read</em> rate instead of the full input rate.<br><br>
|
|
1889
|
+
<strong>Savings per token = input price − cache read price:</strong><br>
|
|
1890
|
+
Opus: $15 − $1.50 = <strong>$13.50</strong>/M tokens<br>
|
|
1891
|
+
Sonnet: $3 − $0.30 = <strong>$2.70</strong>/M tokens<br>
|
|
1892
|
+
Haiku: $0.80 − $0.08 = <strong>$0.72</strong>/M tokens<br><br>
|
|
1893
|
+
Savings shown here are the cumulative total across all sessions where cache hits were recorded.
|
|
1894
|
+
</span>
|
|
1895
|
+
</span>
|
|
1896
|
+
</div>
|
|
1647
1897
|
<div class="db-card-value" style="color:var(--green)" x-text="insights ? fmtCost(insights.totalSavings) : '—'"></div>
|
|
1648
1898
|
<div class="db-card-sub">money saved via prompt cache</div>
|
|
1649
1899
|
</div>
|
|
@@ -1690,28 +1940,9 @@
|
|
|
1690
1940
|
</div>
|
|
1691
1941
|
</template>
|
|
1692
1942
|
</div>
|
|
1693
|
-
</div
|
|
1943
|
+
</div><!-- /stats grid -->
|
|
1694
1944
|
|
|
1695
|
-
<!-- Row 2:
|
|
1696
|
-
<template x-if="sessions.length > 0">
|
|
1697
|
-
<div class="db-latest" @click="openSession(sessions[0])">
|
|
1698
|
-
<div class="db-latest-label">Latest session</div>
|
|
1699
|
-
<div class="db-latest-prompt" x-text="sessions[0].firstPrompt || '(no prompt)'"></div>
|
|
1700
|
-
<div class="db-latest-meta">
|
|
1701
|
-
<template x-if="sessions[0].gitBranch">
|
|
1702
|
-
<span style="display:flex;align-items:center;gap:5px">
|
|
1703
|
-
<div class="branch-square" :style="'background:' + branchColor(sessions[0].gitBranch)"></div>
|
|
1704
|
-
<span x-text="sessions[0].gitBranch"></span>
|
|
1705
|
-
</span>
|
|
1706
|
-
</template>
|
|
1707
|
-
<span x-text="formatDate(sessions[0].modified)"></span>
|
|
1708
|
-
<span x-text="sessions[0].messageCount + ' msgs'"></span>
|
|
1709
|
-
<button class="btn btn-primary btn-sm" style="margin-left:auto" :id="'resume-db'" :disabled="!sessions[0].resumable" :title="!sessions[0].resumable ? 'Session file deleted — cannot resume' : 'Copy command to resume this session'" @click.stop="sessions[0].resumable && resumeSession(sessions[0].sessionId,'resume-db', sessions[0].projectPath)">Resume →</button>
|
|
1710
|
-
</div>
|
|
1711
|
-
</div>
|
|
1712
|
-
</template>
|
|
1713
|
-
|
|
1714
|
-
<!-- Row 3: Heatmap -->
|
|
1945
|
+
<!-- Row 2: Heatmap -->
|
|
1715
1946
|
<template x-if="stats">
|
|
1716
1947
|
<div class="heatmap-wrap">
|
|
1717
1948
|
<div class="heatmap-section-title">Contribution heatmap</div>
|
|
@@ -1761,6 +1992,25 @@
|
|
|
1761
1992
|
</template>
|
|
1762
1993
|
</div>
|
|
1763
1994
|
|
|
1995
|
+
<!-- Latest session -->
|
|
1996
|
+
<template x-if="sessions.length > 0">
|
|
1997
|
+
<div class="db-latest" @click="openSession(sessions[0])" style="margin-bottom:16px">
|
|
1998
|
+
<div class="db-latest-label">Latest session</div>
|
|
1999
|
+
<div class="db-latest-prompt" x-text="sessions[0].firstPrompt || '(no prompt)'"></div>
|
|
2000
|
+
<div class="db-latest-meta">
|
|
2001
|
+
<template x-if="sessions[0].gitBranch">
|
|
2002
|
+
<span style="display:flex;align-items:center;gap:5px">
|
|
2003
|
+
<div class="branch-square" :style="'background:' + branchColor(sessions[0].gitBranch)"></div>
|
|
2004
|
+
<span x-text="sessions[0].gitBranch"></span>
|
|
2005
|
+
</span>
|
|
2006
|
+
</template>
|
|
2007
|
+
<span x-text="formatDate(sessions[0].modified)"></span>
|
|
2008
|
+
<span x-text="sessions[0].messageCount + ' msgs'"></span>
|
|
2009
|
+
<button class="btn btn-primary btn-sm" style="margin-left:auto" :id="'resume-db'" :disabled="!sessions[0].resumable" :title="!sessions[0].resumable ? 'Session file deleted — cannot resume' : 'Copy command to resume this session'" @click.stop="sessions[0].resumable && resumeSession(sessions[0].sessionId,'resume-db', sessions[0].projectPath)">Resume →</button>
|
|
2010
|
+
</div>
|
|
2011
|
+
</div>
|
|
2012
|
+
</template>
|
|
2013
|
+
|
|
1764
2014
|
<!-- Top sessions -->
|
|
1765
2015
|
<template x-if="insights.topSessions && insights.topSessions.length > 0">
|
|
1766
2016
|
<div class="insight-section">
|
|
@@ -1777,7 +2027,34 @@
|
|
|
1777
2027
|
</div>
|
|
1778
2028
|
</template>
|
|
1779
2029
|
|
|
1780
|
-
|
|
2030
|
+
</div><!-- /left column -->
|
|
2031
|
+
|
|
2032
|
+
<!-- Right column: Projects -->
|
|
2033
|
+
<div style="width:240px;min-width:240px;background:var(--white);border:1px solid var(--rule);border-radius:6px;overflow:hidden;position:sticky;top:0">
|
|
2034
|
+
<div style="padding:12px 14px 10px;font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-3);border-bottom:1px solid var(--rule)">Projects</div>
|
|
2035
|
+
<template x-if="projects.length === 0">
|
|
2036
|
+
<div style="padding:16px 14px;font-size:12px;color:var(--ink-3)">No projects</div>
|
|
2037
|
+
</template>
|
|
2038
|
+
<template x-for="proj in projects" :key="proj.dirName">
|
|
2039
|
+
<div @click="openProject(proj)" style="padding:9px 14px;border-bottom:1px solid var(--rule);cursor:pointer;transition:background 0.1s" @mouseenter="$el.style.background='var(--canvas)'" @mouseleave="$el.style.background=''">
|
|
2040
|
+
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:2px">
|
|
2041
|
+
<span style="font-size:12.5px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" x-text="shortProjectName(proj.projectPath) || proj.dirName"></span>
|
|
2042
|
+
<template x-if="!proj.diskExists">
|
|
2043
|
+
<span style="font-size:9px;color:var(--red);flex-shrink:0">missing</span>
|
|
2044
|
+
</template>
|
|
2045
|
+
</div>
|
|
2046
|
+
<div style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--ink-3)">
|
|
2047
|
+
<span x-text="proj.sessionCount + ' sessions'"></span>
|
|
2048
|
+
<template x-if="projectCost(proj.dirName) !== null">
|
|
2049
|
+
<span x-text="fmtCost(projectCost(proj.dirName))" style="font-family:'SF Mono','Fira Code',monospace"></span>
|
|
2050
|
+
</template>
|
|
2051
|
+
</div>
|
|
2052
|
+
<div style="font-size:10.5px;color:var(--ink-4);margin-top:1px" x-text="proj.lastActive ? formatDate(proj.lastActive) : ''"></div>
|
|
2053
|
+
</div>
|
|
2054
|
+
</template>
|
|
2055
|
+
</div><!-- /right column -->
|
|
2056
|
+
|
|
2057
|
+
</div><!-- /two-col layout -->
|
|
1781
2058
|
</div>
|
|
1782
2059
|
</div>
|
|
1783
2060
|
</template>
|
|
@@ -3981,7 +4258,7 @@
|
|
|
3981
4258
|
{ cmd: '/insights', desc: 'Session analytics report' },
|
|
3982
4259
|
{ cmd: '/keybindings', desc: 'Open keybindings file' },
|
|
3983
4260
|
{ cmd: '/login', desc: 'Sign in to Anthropic' },
|
|
3984
|
-
{ cmd: '/logout', desc: 'Sign out
|
|
4261
|
+
{ cmd: '/logout', desc: 'Sign out from Anthropic' },
|
|
3985
4262
|
{ cmd: '/mcp', desc: 'Manage MCP connections and OAuth' },
|
|
3986
4263
|
{ cmd: '/memory', desc: 'Edit CLAUDE.md and manage auto-memory' },
|
|
3987
4264
|
{ cmd: '/model [model]', desc: 'Select or change AI model' },
|
|
@@ -4005,6 +4282,16 @@
|
|
|
4005
4282
|
{ cmd: '/vim', desc: 'Toggle Vim and Normal modes' },
|
|
4006
4283
|
{ cmd: '/voice', desc: 'Enable voice dictation (push to talk)' },
|
|
4007
4284
|
],
|
|
4285
|
+
selectedProject: null,
|
|
4286
|
+
projectSessions: [],
|
|
4287
|
+
projectSessionsLoading: false,
|
|
4288
|
+
projectMemory: [],
|
|
4289
|
+
projectMemorySelected: null,
|
|
4290
|
+
projectMemoryLoading: false,
|
|
4291
|
+
projectClaudeMd: null,
|
|
4292
|
+
projectClaudeMdLoading: false,
|
|
4293
|
+
projectClaudeMdEdit: { active: false, fileIdx: 0, draft: '', saving: false, msg: '' },
|
|
4294
|
+
projectTab: 'sessions',
|
|
4008
4295
|
sessionsTab: 'list',
|
|
4009
4296
|
knowledgeTab: 'memory',
|
|
4010
4297
|
setupTab: 'commands',
|
|
@@ -4042,6 +4329,7 @@
|
|
|
4042
4329
|
|
|
4043
4330
|
initView(v) {
|
|
4044
4331
|
if (v === 'dashboard') { this.loadStats(); this.loadInsights(); }
|
|
4332
|
+
if (v === 'projects') { this.loadProjects(); }
|
|
4045
4333
|
if (v === 'memory') { this.loadMemory(); }
|
|
4046
4334
|
if (v === 'plans') { this.loadPlans(); }
|
|
4047
4335
|
if (v === 'commands' || v === 'skills') { this.loadTools(); }
|
|
@@ -4055,6 +4343,67 @@
|
|
|
4055
4343
|
this.totalSessions = data.reduce((s, p) => s + p.sessionCount, 0);
|
|
4056
4344
|
},
|
|
4057
4345
|
|
|
4346
|
+
async openProject(proj) {
|
|
4347
|
+
this.view = 'projects';
|
|
4348
|
+
this.selectedProject = proj;
|
|
4349
|
+
this.projectTab = 'sessions';
|
|
4350
|
+
this.projectMemorySelected = null;
|
|
4351
|
+
this.projectClaudeMd = null;
|
|
4352
|
+
this.projectClaudeMdEdit = { active: false, fileIdx: 0, draft: '', saving: false, msg: '' };
|
|
4353
|
+
this.loadProjectSessions();
|
|
4354
|
+
this.loadProjectMemory();
|
|
4355
|
+
this.loadProjectClaudeMd();
|
|
4356
|
+
},
|
|
4357
|
+
|
|
4358
|
+
async loadProjectSessions() {
|
|
4359
|
+
if (!this.selectedProject) return;
|
|
4360
|
+
this.projectSessionsLoading = true;
|
|
4361
|
+
this.projectSessions = await fetch(`/api/sessions?project=${this.selectedProject.dirName}`).then(r => r.json());
|
|
4362
|
+
this.projectSessionsLoading = false;
|
|
4363
|
+
},
|
|
4364
|
+
|
|
4365
|
+
async loadProjectMemory() {
|
|
4366
|
+
if (!this.selectedProject) return;
|
|
4367
|
+
this.projectMemoryLoading = true;
|
|
4368
|
+
this.projectMemory = await fetch(`/api/memory?project=${this.selectedProject.dirName}`).then(r => r.json());
|
|
4369
|
+
this.projectMemoryLoading = false;
|
|
4370
|
+
},
|
|
4371
|
+
|
|
4372
|
+
async loadProjectClaudeMd() {
|
|
4373
|
+
if (!this.selectedProject) return;
|
|
4374
|
+
this.projectClaudeMdLoading = true;
|
|
4375
|
+
this.projectClaudeMd = await fetch(`/api/projects/${this.selectedProject.dirName}/claude-md`).then(r => r.json());
|
|
4376
|
+
this.projectClaudeMdLoading = false;
|
|
4377
|
+
},
|
|
4378
|
+
|
|
4379
|
+
async saveProjectClaudeMd() {
|
|
4380
|
+
if (!this.selectedProject || !this.projectClaudeMd) return;
|
|
4381
|
+
const file = this.projectClaudeMd.files[this.projectClaudeMdEdit.fileIdx];
|
|
4382
|
+
if (!file) return;
|
|
4383
|
+
this.projectClaudeMdEdit.saving = true;
|
|
4384
|
+
this.projectClaudeMdEdit.msg = '';
|
|
4385
|
+
try {
|
|
4386
|
+
const res = await fetch(`/api/projects/${this.selectedProject.dirName}/claude-md`, {
|
|
4387
|
+
method: 'PUT',
|
|
4388
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4389
|
+
body: JSON.stringify({ filePath: file.filePath, content: this.projectClaudeMdEdit.draft }),
|
|
4390
|
+
});
|
|
4391
|
+
if (!res.ok) throw new Error((await res.json()).error || 'Error');
|
|
4392
|
+
file.content = this.projectClaudeMdEdit.draft;
|
|
4393
|
+
this.projectClaudeMdEdit.active = false;
|
|
4394
|
+
this.projectClaudeMdEdit.msg = 'Saved';
|
|
4395
|
+
} catch (e) {
|
|
4396
|
+
this.projectClaudeMdEdit.msg = e.message;
|
|
4397
|
+
}
|
|
4398
|
+
this.projectClaudeMdEdit.saving = false;
|
|
4399
|
+
},
|
|
4400
|
+
|
|
4401
|
+
projectCost(dirName) {
|
|
4402
|
+
if (!this.insights) return null;
|
|
4403
|
+
const entry = this.insights.byProject?.find(p => p.dir === dirName);
|
|
4404
|
+
return entry ? entry.cost : null;
|
|
4405
|
+
},
|
|
4406
|
+
|
|
4058
4407
|
async loadBranches() {
|
|
4059
4408
|
const params = this.filterProject ? `?project=${this.filterProject}` : '';
|
|
4060
4409
|
this.branches = await fetch(`/api/branches${params}`).then(r => r.json());
|
|
@@ -4104,7 +4453,7 @@
|
|
|
4104
4453
|
setTimeout(() => { btn.textContent = orig; btn.classList.remove('btn-copied', 'copied'); }, 2000);
|
|
4105
4454
|
}
|
|
4106
4455
|
} catch {
|
|
4107
|
-
prompt('
|
|
4456
|
+
prompt('Copy this command:', cmd);
|
|
4108
4457
|
}
|
|
4109
4458
|
},
|
|
4110
4459
|
|
package/server.js
CHANGED
|
@@ -406,20 +406,95 @@ function loadMemoryFiles(dirName) {
|
|
|
406
406
|
|
|
407
407
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
408
408
|
|
|
409
|
+
// ─── Project helpers ─────────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
async function getProjectPath(dirName) {
|
|
412
|
+
// 1. Try sessions-index.json originalPath (most reliable)
|
|
413
|
+
const indexPath = path.join(PROJECTS_DIR, dirName, 'sessions-index.json');
|
|
414
|
+
try {
|
|
415
|
+
const idx = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
|
416
|
+
if (idx.originalPath) return idx.originalPath;
|
|
417
|
+
} catch {}
|
|
418
|
+
// 2. Try projectPath from loaded entries
|
|
419
|
+
const entries = await loadSessionIndex(dirName);
|
|
420
|
+
if (entries[0]?.projectPath) return entries[0].projectPath;
|
|
421
|
+
// 3. Try cwd field from first few lines of any available JSONL
|
|
422
|
+
const dir = path.join(PROJECTS_DIR, dirName);
|
|
423
|
+
try {
|
|
424
|
+
const jsonlFiles = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
|
|
425
|
+
for (const f of jsonlFiles.slice(0, 3)) {
|
|
426
|
+
const rl = readline.createInterface({ input: fs.createReadStream(path.join(dir, f)), crlfDelay: Infinity });
|
|
427
|
+
let scanned = 0;
|
|
428
|
+
for await (const line of rl) {
|
|
429
|
+
if (!line.trim()) continue;
|
|
430
|
+
try {
|
|
431
|
+
const obj = JSON.parse(line);
|
|
432
|
+
if (obj.cwd) { rl.close(); return obj.cwd; }
|
|
433
|
+
} catch {}
|
|
434
|
+
if (++scanned >= 20) break; // scan up to 20 lines per file
|
|
435
|
+
}
|
|
436
|
+
rl.close();
|
|
437
|
+
}
|
|
438
|
+
} catch {}
|
|
439
|
+
return '';
|
|
440
|
+
}
|
|
441
|
+
|
|
409
442
|
// GET /api/projects
|
|
410
443
|
app.get('/api/projects', async (req, res) => {
|
|
411
444
|
const dirs = getProjectDirs();
|
|
412
445
|
const results = await Promise.all(dirs.map(async dirName => {
|
|
413
446
|
const entries = await loadSessionIndex(dirName);
|
|
414
|
-
const projectPath =
|
|
447
|
+
const projectPath = await getProjectPath(dirName);
|
|
415
448
|
const lastActive = entries.length
|
|
416
449
|
? entries.reduce((max, e) => e.modified > max ? e.modified : max, '')
|
|
417
450
|
: null;
|
|
418
|
-
|
|
451
|
+
const memDir = path.join(PROJECTS_DIR, dirName, 'memory');
|
|
452
|
+
const memoryCount = fs.existsSync(memDir)
|
|
453
|
+
? fs.readdirSync(memDir).filter(f => f.endsWith('.md') && !f.endsWith('.bak')).length
|
|
454
|
+
: 0;
|
|
455
|
+
const diskExists = projectPath ? fs.existsSync(projectPath) : false;
|
|
456
|
+
const branches = [...new Set(entries.map(e => e.gitBranch).filter(Boolean))];
|
|
457
|
+
return { dirName, projectPath, sessionCount: entries.length, lastActive, memoryCount, diskExists, branches };
|
|
419
458
|
}));
|
|
420
459
|
res.json(results.filter(p => p.sessionCount > 0).sort((a, b) => (b.lastActive || '').localeCompare(a.lastActive || '')));
|
|
421
460
|
});
|
|
422
461
|
|
|
462
|
+
// GET /api/projects/:dirName/claude-md
|
|
463
|
+
app.get('/api/projects/:dirName/claude-md', async (req, res) => {
|
|
464
|
+
const { dirName } = req.params;
|
|
465
|
+
const projectPath = await getProjectPath(dirName);
|
|
466
|
+
if (!projectPath) return res.json({ projectPath: '', files: [] });
|
|
467
|
+
const candidates = [
|
|
468
|
+
{ label: 'CLAUDE.md', p: path.join(projectPath, 'CLAUDE.md') },
|
|
469
|
+
{ label: '.claude/CLAUDE.md', p: path.join(projectPath, '.claude', 'CLAUDE.md') },
|
|
470
|
+
{ label: '.claude/CLAUDE.local.md', p: path.join(projectPath, '.claude', 'CLAUDE.local.md') },
|
|
471
|
+
];
|
|
472
|
+
const files = [];
|
|
473
|
+
for (const { label, p } of candidates) {
|
|
474
|
+
if (fs.existsSync(p)) files.push({ label, filePath: p, content: fs.readFileSync(p, 'utf8') });
|
|
475
|
+
}
|
|
476
|
+
res.json({ projectPath, files });
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// PUT /api/projects/:dirName/claude-md
|
|
480
|
+
app.put('/api/projects/:dirName/claude-md', async (req, res) => {
|
|
481
|
+
const { dirName } = req.params;
|
|
482
|
+
const { filePath, content } = req.body;
|
|
483
|
+
if (!filePath || typeof content !== 'string') return res.status(400).json({ error: 'Missing filePath or content' });
|
|
484
|
+
const projectPath = await getProjectPath(dirName);
|
|
485
|
+
if (!projectPath) return res.status(404).json({ error: 'Project not found' });
|
|
486
|
+
// Security: filePath must be within projectPath
|
|
487
|
+
const resolved = path.resolve(filePath);
|
|
488
|
+
const base = path.resolve(projectPath);
|
|
489
|
+
if (!resolved.startsWith(base + path.sep) && resolved !== base) {
|
|
490
|
+
return res.status(403).json({ error: 'Path not allowed' });
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
safeWrite(resolved, content);
|
|
494
|
+
res.json({ ok: true });
|
|
495
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
496
|
+
});
|
|
497
|
+
|
|
423
498
|
// GET /api/sessions
|
|
424
499
|
app.get('/api/sessions', async (req, res) => {
|
|
425
500
|
const { project, branch, search, from, to } = req.query;
|