claude-home 1.2.2 → 1.4.2

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/cli.js CHANGED
@@ -66,10 +66,8 @@ if (args.includes('--help') || args.includes('-h')) {
66
66
  if (subcommand === 'stop') {
67
67
  const { execSync } = require('child_process');
68
68
  try {
69
- const pid = execSync(`lsof -ti:${port}`, { encoding: 'utf8' }).trim();
70
- if (!pid) { console.log(`No claude-home process found on port ${port}`); process.exit(0); }
71
- execSync(`kill ${pid}`);
72
- console.log(`✓ claude-home stopped (pid ${pid})`);
69
+ execSync(`lsof -ti:${port} | xargs kill -9`, { stdio: 'ignore' });
70
+ console.log(`✓ claude-home stopped`);
73
71
  } catch { console.log(`No claude-home process found on port ${port}`); }
74
72
  process.exit(0);
75
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-home",
3
- "version": "1.2.2",
3
+ "version": "1.4.2",
4
4
  "description": "Web dashboard for Claude Code — browse sessions, manage skills, hooks, commands, and agents",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -343,6 +343,9 @@
343
343
 
344
344
  .session-resume-btn:hover { background: var(--red); }
345
345
  .session-resume-btn.copied { background: var(--green) !important; }
346
+ .session-resume-btn:disabled { opacity: 0.35; cursor: not-allowed; background: var(--surface-2); color: var(--ink-3); }
347
+ .session-resume-btn:disabled:hover { background: var(--surface-2); }
348
+ .session-row:hover .session-resume-btn:disabled { opacity: 0.4; }
346
349
 
347
350
  /* ── Chat view ────────────────────────────────────────── */
348
351
  .chat-header {
@@ -397,6 +400,10 @@
397
400
 
398
401
  .message { display: flex; flex-direction: column; margin-bottom: 24px; }
399
402
 
403
+ .message.assistant { flex-direction: row; align-items: flex-start; gap: 10px; }
404
+ .message.assistant .message-role { margin-bottom: 0; flex-shrink: 0; padding-top: 3px; }
405
+ .message.assistant .message-bubble .md-content > *:first-child { margin-top: 0; }
406
+
400
407
  .message-role {
401
408
  font-size: 10px;
402
409
  font-weight: 700;
@@ -431,7 +438,10 @@
431
438
  align-items: center;
432
439
  gap: 8px;
433
440
  margin-top: 6px;
441
+ opacity: 0;
442
+ transition: opacity 0.15s;
434
443
  }
444
+ .message:hover .message-footer { opacity: 1; }
435
445
 
436
446
  .msg-meta { font-size: 10.5px; color: var(--ink-3); }
437
447
 
@@ -443,13 +453,16 @@
443
453
  .md-content p { margin: 6px 0; }
444
454
  .md-content ul, .md-content ol { padding-left: 20px; margin: 6px 0; }
445
455
  .md-content li { margin: 3px 0; }
446
- .md-content code {
456
+ .md-content code, .inline-code {
447
457
  background: var(--canvas);
448
458
  border: 1px solid var(--rule);
449
459
  padding: 1px 5px;
460
+ border-radius: 3px;
450
461
  font-family: 'SF Mono', 'Fira Code', monospace;
451
462
  font-size: 12px;
452
463
  }
464
+ /* keep inline hljs within the bubble background, don't override with theme's white */
465
+ code.inline-code.hljs { background: var(--canvas); }
453
466
  .md-content pre {
454
467
  background: var(--white);
455
468
  border: 1px solid var(--rule);
@@ -1690,7 +1703,7 @@
1690
1703
  </template>
1691
1704
  <span x-text="formatDate(sessions[0].modified)"></span>
1692
1705
  <span x-text="sessions[0].messageCount + ' msgs'"></span>
1693
- <button class="btn btn-primary btn-sm" style="margin-left:auto" :id="'resume-db'" @click.stop="resumeSession(sessions[0].sessionId,'resume-db')">Resume →</button>
1706
+ <button class="btn btn-primary btn-sm" style="margin-left:auto" :id="'resume-db'" :disabled="!sessions[0].resumable" :title="!sessions[0].resumable ? 'Archivo de sesión eliminado — no se puede retomar' : 'Copiar comando para retomar esta sesión'" @click.stop="sessions[0].resumable && resumeSession(sessions[0].sessionId,'resume-db', sessions[0].projectPath)">Resume →</button>
1694
1707
  </div>
1695
1708
  </div>
1696
1709
  </template>
@@ -1854,7 +1867,7 @@
1854
1867
  </div>
1855
1868
  </div>
1856
1869
  <div class="session-right">
1857
- <button class="session-resume-btn" :id="'resume-' + s.sessionId" @click.stop="resumeSession(s.sessionId, 'resume-' + s.sessionId)">Resume →</button>
1870
+ <button class="session-resume-btn" :id="'resume-' + s.sessionId" :disabled="!s.resumable" :title="!s.resumable ? 'Archivo de sesión eliminado — no se puede retomar' : 'Copiar comando para retomar esta sesión'" @click.stop="s.resumable && resumeSession(s.sessionId, 'resume-' + s.sessionId, s.projectPath)">Resume →</button>
1858
1871
  </div>
1859
1872
  </div>
1860
1873
  </template>
@@ -1959,7 +1972,7 @@
1959
1972
  </div>
1960
1973
  </div>
1961
1974
  <div style="display:flex;gap:6px;align-items:center;flex-shrink:0">
1962
- <button class="btn btn-sm" :class="cleanMode ? 'btn-primary' : ''" style="background:var(--canvas-2);color:var(--ink)" :style="cleanMode ? 'background:var(--blue);color:#fff' : ''" @click="cleanMode=!cleanMode" title="Toggle clean view">Clean</button>
1975
+ <button class="btn btn-sm" :class="cleanMode ? 'btn-primary' : ''" style="background:var(--canvas-2);color:var(--ink)" :style="cleanMode ? 'background:var(--blue);color:#fff' : ''" @click="cleanMode=!cleanMode" title="Toggle focus view">Focus</button>
1963
1976
  <div style="position:relative">
1964
1977
  <button class="btn btn-sm" style="background:var(--canvas-2);color:var(--ink);display:flex;align-items:center;gap:4px" @click="exportDropOpen=!exportDropOpen" @click.outside="exportDropOpen=false">
1965
1978
  Export <svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3.5L5 6.5L8 3.5"/></svg>
@@ -1971,7 +1984,7 @@
1971
1984
  </div>
1972
1985
  <button class="btn btn-sm" style="background:var(--red-dim,#3a1a1a);color:var(--red);border:1px solid var(--red)" @click="deleteSession()" x-show="!deletingSession">Delete</button>
1973
1986
  <span x-show="deletingSession" style="font-size:12px;color:var(--ink-3)">Deleting…</span>
1974
- <button class="btn btn-primary btn-sm" id="resume-detail" @click="resumeSession(selectedSession.sessionId, 'resume-detail')">
1987
+ <button class="btn btn-primary btn-sm" id="resume-detail" :disabled="!sessionDetail?.resumable" :title="!sessionDetail?.resumable ? 'Archivo de sesión eliminado — no se puede retomar' : 'Copiar comando para retomar esta sesión'" @click="sessionDetail?.resumable && resumeSession(selectedSession.sessionId, 'resume-detail', selectedSession.projectPath)">
1975
1988
  Resume →
1976
1989
  </button>
1977
1990
  <span x-show="exportMsg" x-text="exportMsg" style="font-size:12px;color:var(--green)" x-transition></span>
@@ -1992,8 +2005,11 @@
1992
2005
  <div class="message-bubble">
1993
2006
  <template x-for="(block, bi) in getUserBlocks(msg)" :key="bi">
1994
2007
  <div>
2008
+ <template x-if="block.type === 'slash-command'">
2009
+ <span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:2px 8px;border-radius:4px;background:var(--canvas-2);border:1px solid var(--rule-2);color:var(--ink-2);font-family:monospace" x-text="block.command"></span>
2010
+ </template>
1995
2011
  <template x-if="block.type === 'text'">
1996
- <div x-text="block.text" style="white-space:pre-wrap"></div>
2012
+ <div x-html="renderInlineCode(block.text)" style="white-space:pre-wrap"></div>
1997
2013
  </template>
1998
2014
  <template x-if="block.type === 'tool_result' && !cleanMode">
1999
2015
  <div class="collapsible" style="margin-top:6px">
@@ -2022,7 +2038,9 @@
2022
2038
  <!-- Assistant message -->
2023
2039
  <template x-if="msg.type === 'assistant'">
2024
2040
  <div class="message assistant" x-show="!cleanMode || msgHasText(msg)">
2025
- <div class="message-role">Claude</div>
2041
+ <div class="message-role">
2042
+ <svg fill="currentColor" fill-rule="evenodd" width="16" height="16" viewBox="0 0 24 24" style="color:var(--ink-3)" xmlns="http://www.w3.org/2000/svg"><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"></path></svg>
2043
+ </div>
2026
2044
  <div class="message-bubble">
2027
2045
  <template x-for="(block, bi) in getAssistantBlocks(msg)" :key="bi">
2028
2046
  <div>
@@ -3623,8 +3641,10 @@
3623
3641
  <input class="perm-add-input" style="width:100%" x-model="ruleBuilder.specifier"
3624
3642
  placeholder="npm run * / git commit * / * --help *"
3625
3643
  @keydown.enter="ruleBuilderAdd()" />
3626
- <div style="font-size:11px;color:var(--ink-3);margin-top:5px">
3627
- <code>Bash(ls *)</code> coincide con <code>ls -la</code> pero no con <code>lsof</code> — el espacio antes de <code>*</code> actúa como límite de palabra.
3644
+ <div style="font-size:11px;color:var(--ink-3);margin-top:5px;display:flex;flex-direction:column;gap:4px">
3645
+ <span><code>Bash(ls *)</code> coincide con <code>ls -la</code> pero no con <code>lsof</code> — el espacio antes de <code>*</code> actúa como límite de palabra.</span>
3646
+ <span>⚠️ Comandos con <code>&&</code>, <code>|</code> o redirecciones pueden ser bloqueados por la detección automática de patrones peligrosos, aunque estén en allow.</span>
3647
+ <span>💡 Para comandos de una sola herramienta usa el prefijo sin encadenar: <code>npm publish *</code> en lugar de <code>cd /dir && npm publish</code>.</span>
3628
3648
  </div>
3629
3649
  </div>
3630
3650
  </template>
@@ -3811,14 +3831,26 @@
3811
3831
 
3812
3832
  <script>
3813
3833
  marked.setOptions({ gfm: true, breaks: true });
3814
- marked.use({ renderer: { code(token) {
3815
- const lang = token.lang || '';
3816
- const valid = hljs.getLanguage(lang) ? lang : 'plaintext';
3817
- try {
3818
- const highlighted = hljs.highlight(token.text, { language: valid }).value;
3819
- return `<pre><code class="hljs language-${valid}">${highlighted}</code></pre>`;
3820
- } catch { return `<pre><code>${token.text}</code></pre>`; }
3821
- }}});
3834
+ marked.use({ renderer: {
3835
+ code(token) {
3836
+ const lang = token.lang || '';
3837
+ const valid = hljs.getLanguage(lang) ? lang : 'plaintext';
3838
+ try {
3839
+ const highlighted = hljs.highlight(token.text, { language: valid }).value;
3840
+ return `<pre><code class="hljs language-${valid}">${highlighted}</code></pre>`;
3841
+ } catch { return `<pre><code>${token.text}</code></pre>`; }
3842
+ },
3843
+ codespan(token) {
3844
+ try {
3845
+ const highlighted = hljs.highlightAuto(token.text).value;
3846
+ return `<code class="inline-code hljs">${highlighted}</code>`;
3847
+ } catch { return `<code class="inline-code">${token.text}</code>`; }
3848
+ }
3849
+ }});
3850
+
3851
+ function hljsInline(text) {
3852
+ try { return hljs.highlightAuto(text).value; } catch { return text; }
3853
+ }
3822
3854
 
3823
3855
  function app() {
3824
3856
  return {
@@ -3854,7 +3886,7 @@
3854
3886
  deletingSession: false,
3855
3887
  exportDropOpen: false,
3856
3888
  exportMsg: '',
3857
- cleanMode: false,
3889
+ cleanMode: true,
3858
3890
  planExportMsg: '',
3859
3891
  planExportOpen: false,
3860
3892
  sidebarW: parseInt(localStorage.getItem('cm:sidebarW') || '260'),
@@ -4045,15 +4077,21 @@
4045
4077
  await this.openSession(s);
4046
4078
  },
4047
4079
 
4048
- async resumeSession(sessionId, btnId) {
4049
- const cmd = `claude -r ${sessionId}`;
4050
- await navigator.clipboard.writeText(cmd);
4080
+ async resumeSession(sessionId, btnId, projectPath) {
4081
+ const cmd = projectPath
4082
+ ? `cd "${projectPath}" && claude -r ${sessionId}`
4083
+ : `claude -r ${sessionId}`;
4051
4084
  const btn = document.getElementById(btnId);
4052
- if (btn) {
4053
- const orig = btn.textContent;
4054
- btn.textContent = 'Copied!';
4055
- btn.classList.add('btn-copied', 'copied');
4056
- setTimeout(() => { btn.textContent = orig; btn.classList.remove('btn-copied', 'copied'); }, 2000);
4085
+ try {
4086
+ await navigator.clipboard.writeText(cmd);
4087
+ if (btn) {
4088
+ const orig = btn.textContent;
4089
+ btn.textContent = 'Copied!';
4090
+ btn.classList.add('btn-copied', 'copied');
4091
+ setTimeout(() => { btn.textContent = orig; btn.classList.remove('btn-copied', 'copied'); }, 2000);
4092
+ }
4093
+ } catch {
4094
+ prompt('Copia este comando:', cmd);
4057
4095
  }
4058
4096
  },
4059
4097
 
@@ -4253,9 +4291,13 @@
4253
4291
 
4254
4292
  getUserBlocks(msg) {
4255
4293
  const content = msg.message?.content;
4256
- if (typeof content === 'string') return [{ type: 'text', text: content }];
4257
- if (Array.isArray(content)) return content;
4258
- return [];
4294
+ const blocks = typeof content === 'string' ? [{ type: 'text', text: content }] : Array.isArray(content) ? content : [];
4295
+ return blocks.map(b => {
4296
+ if (b.type !== 'text') return b;
4297
+ const m = b.text?.match(/<command-name>([^<]+)<\/command-name>/);
4298
+ if (m) return { type: 'slash-command', command: m[1].startsWith('/') ? m[1] : '/' + m[1] };
4299
+ return b;
4300
+ });
4259
4301
  },
4260
4302
 
4261
4303
  getAssistantBlocks(msg) {
@@ -4295,6 +4337,16 @@
4295
4337
 
4296
4338
  renderMd(text) { try { return marked.parse(text); } catch { return text; } },
4297
4339
 
4340
+ renderInlineCode(text) {
4341
+ const esc = text
4342
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;')
4343
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
4344
+ return esc.replace(/`([^`\n]+)`/g, (_, code) => {
4345
+ const decoded = code.replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&amp;/g,'&').replace(/&quot;/g,'"');
4346
+ return `<code class="inline-code hljs">${hljsInline(decoded)}</code>`;
4347
+ });
4348
+ },
4349
+
4298
4350
  shortProjectName(p) {
4299
4351
  if (!p) return 'unknown';
4300
4352
  const parts = p.replace(/\\/g, '/').split('/');
package/server.js CHANGED
@@ -200,10 +200,15 @@ async function readFirstMessage(filePath) {
200
200
  if (!timestamp && obj.timestamp) timestamp = obj.timestamp;
201
201
  if (!firstPrompt && obj.type === 'user') {
202
202
  const content = obj.message?.content;
203
- if (typeof content === 'string') firstPrompt = content.slice(0, 300);
204
- else if (Array.isArray(content)) {
205
- const txt = content.find(c => c.type === 'text');
206
- if (txt) firstPrompt = txt.text.slice(0, 300);
203
+ const isHook = typeof content === 'string'
204
+ ? content.includes('<local-command-caveat>')
205
+ : Array.isArray(content) && content.every(c => c.type === 'text' && c.text?.includes('<local-command-caveat>'));
206
+ if (!isHook) {
207
+ if (typeof content === 'string' && !content.includes('<command-name>')) firstPrompt = content.slice(0, 300);
208
+ else if (Array.isArray(content)) {
209
+ const txt = content.find(c => c.type === 'text' && !c.text?.includes('<command-name>') && !c.text?.includes('<local-command-caveat>'));
210
+ if (txt) firstPrompt = txt.text.slice(0, 300);
211
+ }
207
212
  }
208
213
  }
209
214
  if (firstPrompt && gitBranch && count > 5) break;
@@ -267,7 +272,13 @@ async function loadSessionIndex(dirName) {
267
272
  };
268
273
  }));
269
274
 
270
- const allEntries = [...indexedEntries, ...unindexedEntries];
275
+ const allEntries = [
276
+ ...indexedEntries.map(e => {
277
+ const exists = fs.existsSync(e.fullPath || path.join(dir, `${e.sessionId}.jsonl`));
278
+ return { ...e, orphaned: !exists, resumable: exists };
279
+ }),
280
+ ...unindexedEntries.map(e => ({ ...e, resumable: true })),
281
+ ];
271
282
  indexCache.set(dirName, { indexMtime, dirMtime: dirStat, entries: allEntries });
272
283
  return allEntries;
273
284
  }
@@ -291,6 +302,15 @@ async function parseJsonl(filePath, { includeNoise = false, searchText = null }
291
302
  try { obj = JSON.parse(line); } catch { continue; }
292
303
  if (!includeNoise && NOISE_TYPES.has(obj.type)) continue;
293
304
 
305
+ // Filter out hook output messages (local-command-caveat)
306
+ if (obj.type === 'user') {
307
+ const c = obj.message?.content;
308
+ const isHookMsg = typeof c === 'string'
309
+ ? c.includes('<local-command-caveat>')
310
+ : Array.isArray(c) && c.every(b => b.type === 'text' && b.text?.includes('<local-command-caveat>'));
311
+ if (isHookMsg) continue;
312
+ }
313
+
294
314
  if (searchText) {
295
315
  const text = extractText(obj);
296
316
  if (!text.toLowerCase().includes(searchText.toLowerCase())) continue;
@@ -424,6 +444,7 @@ app.get('/api/sessions/:project/:sessionId', async (req, res) => {
424
444
  const { project, sessionId } = req.params;
425
445
  const filePath = path.join(PROJECTS_DIR, project, `${sessionId}.jsonl`);
426
446
  try {
447
+ const resumable = fs.existsSync(filePath);
427
448
  const messages = await parseJsonl(filePath);
428
449
  const tokens = aggregateTokens(messages);
429
450
  const models = [...new Set(
@@ -435,7 +456,7 @@ app.get('/api/sessions/:project/:sessionId', async (req, res) => {
435
456
  const cost = calculateCost(tokens, primaryModel);
436
457
  const savings = cacheSavings(tokens, primaryModel);
437
458
  const carbon = calculateCarbon(tokens, primaryModel);
438
- res.json({ sessionId, tokens, models, cost, savings, carbon, messages });
459
+ res.json({ sessionId, tokens, models, cost, savings, carbon, messages, resumable });
439
460
  } catch (e) {
440
461
  res.status(500).json({ error: e.message });
441
462
  }