ccakashic 0.2.1 → 0.2.4
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/bin/ccakashic.js +139 -8
- package/lib/discover.js +69 -1
- package/lib/html-generator.js +23 -0
- package/lib/template-assets.js +17 -0
- package/package.json +1 -1
package/bin/ccakashic.js
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
|
|
4
4
|
const http = require('http');
|
|
5
5
|
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
6
7
|
const path = require('path');
|
|
7
8
|
const { exec } = require('child_process');
|
|
8
|
-
const { listProjects, listSessions } = require('../lib/discover');
|
|
9
|
+
const { listProjects, listSessions, findSessionForCwd } = require('../lib/discover');
|
|
9
10
|
const { parseSession } = require('../lib/parser');
|
|
10
11
|
const { generate } = require('../lib/html-generator');
|
|
11
12
|
const { generateIndex, generateSessionList } = require('../lib/pages');
|
|
13
|
+
const pkg = require('../package.json');
|
|
12
14
|
|
|
13
15
|
function openInBrowser(url) {
|
|
14
16
|
const cmd = process.platform === 'darwin' ? 'open'
|
|
@@ -18,14 +20,22 @@ function openInBrowser(url) {
|
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
const PORT = parseInt(process.env.CCAKASHIC_PORT) || 3333;
|
|
23
|
+
const MAX_PORT_TRIES = 20;
|
|
24
|
+
const LOCK_FILE = path.join(os.tmpdir(), `ccakashic-${os.userInfo().username || 'user'}.json`);
|
|
21
25
|
|
|
22
26
|
const server = http.createServer(async (req, res) => {
|
|
23
27
|
try {
|
|
24
28
|
const url = new URL(req.url, `http://localhost`);
|
|
25
29
|
const pathname = url.pathname;
|
|
26
30
|
|
|
31
|
+
// Health/identity endpoint used to detect an already-running ccakashic
|
|
32
|
+
if (pathname === '/__ccakashic') {
|
|
33
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
34
|
+
res.end(JSON.stringify({ name: 'ccakashic', version: pkg.version }));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
if (pathname === '/' || pathname === '') {
|
|
28
|
-
// Project index
|
|
29
39
|
const projects = listProjects();
|
|
30
40
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
31
41
|
res.end(generateIndex(projects));
|
|
@@ -34,7 +44,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
34
44
|
|
|
35
45
|
const projectMatch = pathname.match(/^\/project\/(.+)$/);
|
|
36
46
|
if (projectMatch && !pathname.includes('/session/')) {
|
|
37
|
-
// Session list for a project
|
|
38
47
|
const rawName = decodeURIComponent(projectMatch[1]);
|
|
39
48
|
const projects = listProjects();
|
|
40
49
|
const project = projects.find(p => p.rawName === rawName);
|
|
@@ -51,7 +60,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
51
60
|
|
|
52
61
|
const sessionMatch = pathname.match(/^\/project\/(.+)\/session\/(.+)$/);
|
|
53
62
|
if (sessionMatch) {
|
|
54
|
-
// Render a specific session
|
|
55
63
|
const rawName = decodeURIComponent(sessionMatch[1]);
|
|
56
64
|
const sessionId = decodeURIComponent(sessionMatch[2]);
|
|
57
65
|
const projects = listProjects();
|
|
@@ -85,10 +93,133 @@ const server = http.createServer(async (req, res) => {
|
|
|
85
93
|
}
|
|
86
94
|
});
|
|
87
95
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
96
|
+
async function buildOpenUrl(baseUrl) {
|
|
97
|
+
try {
|
|
98
|
+
const match = await findSessionForCwd(process.cwd());
|
|
99
|
+
if (match) {
|
|
100
|
+
console.log(`Detected session for ${process.cwd()} → opening at bottom`);
|
|
101
|
+
return `${baseUrl}/project/${encodeURIComponent(match.projectRawName)}/session/${encodeURIComponent(match.sessionId)}#session-bottom`;
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.error('Failed to auto-detect session:', err.message);
|
|
105
|
+
}
|
|
106
|
+
return baseUrl;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function probeCcakashic(port) {
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
const req = http.request({
|
|
112
|
+
host: '127.0.0.1',
|
|
113
|
+
port,
|
|
114
|
+
path: '/__ccakashic',
|
|
115
|
+
method: 'GET',
|
|
116
|
+
timeout: 500,
|
|
117
|
+
}, (res) => {
|
|
118
|
+
let data = '';
|
|
119
|
+
res.on('data', chunk => { data += chunk; });
|
|
120
|
+
res.on('end', () => {
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(data);
|
|
123
|
+
resolve(parsed && parsed.name === 'ccakashic');
|
|
124
|
+
} catch {
|
|
125
|
+
resolve(false);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
req.on('error', () => resolve(false));
|
|
130
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
131
|
+
req.end();
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readLockPort() {
|
|
136
|
+
try {
|
|
137
|
+
const data = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf-8'));
|
|
138
|
+
return typeof data.port === 'number' ? data.port : null;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function writeLockFile(port) {
|
|
145
|
+
try {
|
|
146
|
+
fs.writeFileSync(LOCK_FILE, JSON.stringify({ port, pid: process.pid, startedAt: Date.now() }));
|
|
147
|
+
} catch {
|
|
148
|
+
// best-effort
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function cleanupLockFile() {
|
|
153
|
+
try { fs.unlinkSync(LOCK_FILE); } catch {}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function listenOnPort(port) {
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
const onError = (err) => { server.off('listening', onListening); reject(err); };
|
|
159
|
+
const onListening = () => { server.off('error', onError); resolve(); };
|
|
160
|
+
server.once('error', onError);
|
|
161
|
+
server.once('listening', onListening);
|
|
162
|
+
server.listen(port, '127.0.0.1');
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function findExistingCcakashic(startPort) {
|
|
167
|
+
const lockPort = readLockPort();
|
|
168
|
+
if (lockPort && await probeCcakashic(lockPort)) return lockPort;
|
|
169
|
+
if (startPort !== lockPort && await probeCcakashic(startPort)) return startPort;
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function startServer(startPort) {
|
|
174
|
+
for (let i = 0; i < MAX_PORT_TRIES; i++) {
|
|
175
|
+
const port = startPort + i;
|
|
176
|
+
try {
|
|
177
|
+
await listenOnPort(port);
|
|
178
|
+
return port;
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (err.code !== 'EADDRINUSE') throw err;
|
|
181
|
+
// Port is taken by something else; see if it's ccakashic
|
|
182
|
+
if (await probeCcakashic(port)) return -port; // negative = reuse signal
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
throw new Error(`No available port after ${MAX_PORT_TRIES} tries starting at ${startPort}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function main() {
|
|
189
|
+
const existing = await findExistingCcakashic(PORT);
|
|
190
|
+
if (existing) {
|
|
191
|
+
const url = `http://127.0.0.1:${existing}`;
|
|
192
|
+
console.log(`Reusing existing ccakashic at ${url}`);
|
|
193
|
+
writeLockFile(existing);
|
|
194
|
+
openInBrowser(await buildOpenUrl(url));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const result = await startServer(PORT);
|
|
199
|
+
if (result < 0) {
|
|
200
|
+
const port = -result;
|
|
201
|
+
const url = `http://127.0.0.1:${port}`;
|
|
202
|
+
console.log(`Reusing existing ccakashic at ${url}`);
|
|
203
|
+
writeLockFile(port);
|
|
204
|
+
openInBrowser(await buildOpenUrl(url));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const port = result;
|
|
209
|
+
const url = `http://127.0.0.1:${port}`;
|
|
91
210
|
console.log(`ccakashic running at ${url}`);
|
|
92
211
|
console.log('Press Ctrl+C to stop');
|
|
93
|
-
|
|
212
|
+
writeLockFile(port);
|
|
213
|
+
|
|
214
|
+
const cleanup = () => { cleanupLockFile(); process.exit(0); };
|
|
215
|
+
process.on('SIGINT', cleanup);
|
|
216
|
+
process.on('SIGTERM', cleanup);
|
|
217
|
+
process.on('exit', cleanupLockFile);
|
|
218
|
+
|
|
219
|
+
openInBrowser(await buildOpenUrl(url));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
main().catch((err) => {
|
|
223
|
+
console.error(err);
|
|
224
|
+
process.exit(1);
|
|
94
225
|
});
|
package/lib/discover.js
CHANGED
|
@@ -150,4 +150,72 @@ async function listSessions(projectDir) {
|
|
|
150
150
|
return sessions;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
function readCwdFromSession(filePath) {
|
|
154
|
+
return new Promise((resolve) => {
|
|
155
|
+
const rl = readline.createInterface({
|
|
156
|
+
input: fs.createReadStream(filePath, { encoding: 'utf-8' }),
|
|
157
|
+
crlfDelay: Infinity,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
let found = null;
|
|
161
|
+
rl.on('line', (line) => {
|
|
162
|
+
if (found) return;
|
|
163
|
+
try {
|
|
164
|
+
const obj = JSON.parse(line);
|
|
165
|
+
if (obj.cwd) {
|
|
166
|
+
found = obj.cwd;
|
|
167
|
+
rl.close();
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// skip
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
rl.on('close', () => resolve(found));
|
|
175
|
+
rl.on('error', () => resolve(found));
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function latestSessionFile(projectDir) {
|
|
180
|
+
const files = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
|
|
181
|
+
if (!files.length) return null;
|
|
182
|
+
let best = null;
|
|
183
|
+
let bestMtime = 0;
|
|
184
|
+
for (const f of files) {
|
|
185
|
+
const mtime = fs.statSync(path.join(projectDir, f)).mtimeMs;
|
|
186
|
+
if (mtime > bestMtime) {
|
|
187
|
+
bestMtime = mtime;
|
|
188
|
+
best = f;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return best ? { file: best, mtimeMs: bestMtime } : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function findSessionForCwd(cwd) {
|
|
195
|
+
// Pick the project whose cwd is the most specific (longest) match for the
|
|
196
|
+
// given cwd. A brand-new session in a specific subdir should win over an
|
|
197
|
+
// older, more-active ancestor project.
|
|
198
|
+
const projects = listProjects();
|
|
199
|
+
let bestMatch = null;
|
|
200
|
+
let bestLen = -1;
|
|
201
|
+
|
|
202
|
+
for (const project of projects) {
|
|
203
|
+
const latest = latestSessionFile(project.dir);
|
|
204
|
+
if (!latest) continue;
|
|
205
|
+
const projectCwd = await readCwdFromSession(path.join(project.dir, latest.file));
|
|
206
|
+
if (!projectCwd) continue;
|
|
207
|
+
|
|
208
|
+
const isMatch = cwd === projectCwd || cwd.startsWith(projectCwd + path.sep);
|
|
209
|
+
if (isMatch && projectCwd.length > bestLen) {
|
|
210
|
+
bestMatch = {
|
|
211
|
+
projectRawName: project.rawName,
|
|
212
|
+
sessionId: path.basename(latest.file, '.jsonl'),
|
|
213
|
+
};
|
|
214
|
+
bestLen = projectCwd.length;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return bestMatch;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = { listProjects, listSessions, decodeDirName, findSessionForCwd, CLAUDE_DIR };
|
package/lib/html-generator.js
CHANGED
|
@@ -384,6 +384,9 @@ ${detailLayoutCSS()}
|
|
|
384
384
|
</nav>
|
|
385
385
|
<main class="chat-container" id="session-top">
|
|
386
386
|
${groupsHtml}
|
|
387
|
+
<div class="load-more-row">
|
|
388
|
+
<button class="load-more-btn" id="loadMoreBtn" type="button">Load more ↻</button>
|
|
389
|
+
</div>
|
|
387
390
|
<div id="session-bottom"></div>
|
|
388
391
|
</main>
|
|
389
392
|
</div>
|
|
@@ -407,6 +410,26 @@ function detailLayoutCSS() {
|
|
|
407
410
|
min-width: 0;
|
|
408
411
|
}
|
|
409
412
|
|
|
413
|
+
/* Load more (reload) button at the bottom of the thread */
|
|
414
|
+
.load-more-row {
|
|
415
|
+
display: flex;
|
|
416
|
+
justify-content: center;
|
|
417
|
+
margin: 24px 0 12px;
|
|
418
|
+
}
|
|
419
|
+
.load-more-btn {
|
|
420
|
+
padding: 8px 18px;
|
|
421
|
+
font-size: 0.85rem;
|
|
422
|
+
color: var(--text);
|
|
423
|
+
background: var(--bg-secondary);
|
|
424
|
+
border: 1px solid var(--border);
|
|
425
|
+
border-radius: 6px;
|
|
426
|
+
cursor: pointer;
|
|
427
|
+
transition: background 0.1s;
|
|
428
|
+
}
|
|
429
|
+
.load-more-btn:hover {
|
|
430
|
+
background: var(--bg-hover, var(--border));
|
|
431
|
+
}
|
|
432
|
+
|
|
410
433
|
/* Stats bar */
|
|
411
434
|
.stats-bar {
|
|
412
435
|
display: flex;
|
package/lib/template-assets.js
CHANGED
|
@@ -633,6 +633,23 @@ function getAppJS() {
|
|
|
633
633
|
el.innerHTML = renderMarkdown(el.textContent);
|
|
634
634
|
});
|
|
635
635
|
|
|
636
|
+
// Scroll to bottom when hash is #session-bottom. We re-scroll here (after
|
|
637
|
+
// markdown rendering) because the DOM height grows when markdown expands,
|
|
638
|
+
// making the browser's initial anchor scroll land in the wrong place.
|
|
639
|
+
if (window.location.hash === '#session-bottom') {
|
|
640
|
+
window.scrollTo(0, document.body.scrollHeight);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Load more: reload the page to pick up any newly appended messages, then
|
|
644
|
+
// scroll back to the bottom.
|
|
645
|
+
var loadMoreBtn = document.getElementById('loadMoreBtn');
|
|
646
|
+
if (loadMoreBtn) {
|
|
647
|
+
loadMoreBtn.addEventListener('click', function() {
|
|
648
|
+
window.location.hash = 'session-bottom';
|
|
649
|
+
window.location.reload();
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
636
653
|
// Keyboard navigation: j/k to move between user messages
|
|
637
654
|
var msgEls = Array.from(document.querySelectorAll('.msg-user, .msg-assistant'));
|
|
638
655
|
var currentIdx = -1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccakashic",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Browse Claude Code session logs (~/.claude/projects/) as beautiful HTML in your browser — an Akashic Record of your Claude Code sessions",
|
|
5
5
|
"bin": {
|
|
6
6
|
"ccakashic": "bin/ccakashic.js"
|