fix(cli): -c picks the most recently used session

This commit is contained in:
Brooklyn Nicholson 2026-04-26 16:17:39 -05:00
parent b36007b246
commit bde89c169b
4 changed files with 183 additions and 14 deletions

View file

@ -596,15 +596,32 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
def _resolve_last_session(source: str = "cli") -> Optional[str]: def _resolve_last_session(source: str = "cli") -> Optional[str]:
"""Look up the most recent session ID for a source.""" """Look up the most recently *used* session ID for a source.
Previously this returned the most recently *started* session, which meant
`hermes -c` could skip the session you just closed if a newer one had been
opened earlier in a different window. We now order by last_active
(max message timestamp, falling back to started_at) so -c always resumes
the most recent conversation you actually touched.
"""
try: try:
from hermes_state import SessionDB from hermes_state import SessionDB
db = SessionDB() db = SessionDB()
sessions = db.search_sessions(source=source, limit=1) sessions = db.search_sessions(source=source, limit=20)
db.close() db.close()
if sessions: if not sessions:
return sessions[0]["id"] return None
def _last_active(s: dict) -> float:
v = s.get("last_active") or s.get("started_at") or 0
try:
return float(v)
except (TypeError, ValueError):
return 0.0
sessions.sort(key=_last_active, reverse=True)
return sessions[0]["id"]
except Exception: except Exception:
pass pass
return None return None

View file

@ -0,0 +1,61 @@
"""Verify `hermes -c` picks the session the user most recently used."""
from __future__ import annotations
from hermes_cli.main import _resolve_last_session
class _FakeDB:
def __init__(self, rows):
self._rows = rows
self.closed = False
def search_sessions(self, source=None, limit=20, **_kw):
rows = [r for r in self._rows if r.get("source") == source] if source else list(self._rows)
return rows[:limit]
def close(self):
self.closed = True
def test_resolve_last_session_prefers_last_active_over_started_at(monkeypatch):
# `search_sessions` returns in started_at DESC order, but the most recently
# *touched* session may have been started earlier. -c should pick by
# last_active so closing the active session and typing `hermes -c` resumes
# that one, not an older-but-newer-started session from another window.
rows = [
{
"id": "new_started_old_active",
"source": "cli",
"started_at": 1000.0,
"last_active": 100.0,
},
{
"id": "old_started_recently_active",
"source": "cli",
"started_at": 500.0,
"last_active": 999.0,
},
]
fake_db = _FakeDB(rows)
monkeypatch.setattr("hermes_state.SessionDB", lambda: fake_db)
assert _resolve_last_session("cli") == "old_started_recently_active"
assert fake_db.closed
def test_resolve_last_session_returns_none_when_empty(monkeypatch):
monkeypatch.setattr("hermes_state.SessionDB", lambda: _FakeDB([]))
assert _resolve_last_session("cli") is None
def test_resolve_last_session_falls_back_to_started_at(monkeypatch):
# When last_active is missing entirely (legacy row), fall back to
# started_at so the helper still picks the newest session.
rows = [
{"id": "older", "source": "cli", "started_at": 10.0},
{"id": "newer", "source": "cli", "started_at": 20.0},
]
monkeypatch.setattr("hermes_state.SessionDB", lambda: _FakeDB(rows))
assert _resolve_last_session("cli") == "newer"

View file

@ -8,6 +8,7 @@ import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStor
import { $uiState } from '../app/uiStore.js' import { $uiState } from '../app/uiStore.js'
import { PLACEHOLDER } from '../content/placeholders.js' import { PLACEHOLDER } from '../content/placeholders.js'
import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js' import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
import { PerfPane } from '../lib/perfPane.js'
import { AgentsOverlay } from './agentsOverlay.js' import { AgentsOverlay } from './agentsOverlay.js'
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
@ -248,23 +249,31 @@ export const AppLayout = memo(function AppLayout({
<Box flexDirection="column" flexGrow={1}> <Box flexDirection="column" flexGrow={1}>
<Box flexDirection="row" flexGrow={1}> <Box flexDirection="row" flexGrow={1}>
{overlay.agents ? ( {overlay.agents ? (
<AgentsOverlayPane /> <PerfPane id="agents">
<AgentsOverlayPane />
</PerfPane>
) : ( ) : (
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} /> <PerfPane id="transcript">
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
</PerfPane>
)} )}
</Box> </Box>
{!overlay.agents && ( {!overlay.agents && (
<> <>
<PromptZone <PerfPane id="prompt">
cols={composer.cols} <PromptZone
onApprovalChoice={actions.answerApproval} cols={composer.cols}
onClarifyAnswer={actions.answerClarify} onApprovalChoice={actions.answerApproval}
onSecretSubmit={actions.answerSecret} onClarifyAnswer={actions.answerClarify}
onSudoSubmit={actions.answerSudo} onSecretSubmit={actions.answerSecret}
/> onSudoSubmit={actions.answerSudo}
/>
</PerfPane>
<ComposerPane actions={actions} composer={composer} status={status} /> <PerfPane id="composer">
<ComposerPane actions={actions} composer={composer} status={status} />
</PerfPane>
</> </>
)} )}
</Box> </Box>

View file

@ -0,0 +1,82 @@
// Perf instrumentation: wraps React.Profiler around named panes and writes
// commit timings to a log file when HERMES_DEV_PERF is set. Enabled per-run
// via the env var; zero-cost (Profiler is replaced by a Fragment) when off.
//
// Log format: one JSON object per line, for easy `jq` filtering. We only
// log commits that exceed a threshold (default 2ms) so the file doesn't
// fill up with sub-millisecond idle renders. Tune via HERMES_DEV_PERF_MS.
//
// Usage in consumers:
// import { PerfPane } from './perfPane.js'
// <PerfPane id="transcript"> ... </PerfPane>
//
// Inspect with:
// tail -f ~/.hermes/perf.log | jq -c 'select(.actualMs > 8)'
// jq -s 'group_by(.id) | map({id: .[0].id, count: length, p50: (sort_by(.actualMs) | .[length/2|floor].actualMs), p99: (sort_by(.actualMs) | .[length*0.99|floor].actualMs)})' ~/.hermes/perf.log
import { appendFileSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react'
const ENABLED = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_DEV_PERF ?? '').trim())
const THRESHOLD_MS = Number(process.env.HERMES_DEV_PERF_MS ?? '2') || 2
const LOG_PATH = process.env.HERMES_DEV_PERF_LOG?.trim() || join(homedir(), '.hermes', 'perf.log')
let initialized = false
const ensureLogDir = () => {
if (initialized) {
return
}
initialized = true
try {
mkdirSync(dirname(LOG_PATH), { recursive: true })
} catch {
// Best-effort — if we can't create the dir (readonly fs, /tmp, etc.)
// the appendFileSync calls below will throw silently and we drop the
// sample. Perf logging should never crash the TUI.
}
}
const onRender: ProfilerOnRenderCallback = (id, phase, actualMs, baseMs, startTime, commitTime) => {
if (actualMs < THRESHOLD_MS) {
return
}
ensureLogDir()
const row = {
actualMs: Math.round(actualMs * 100) / 100,
baseMs: Math.round(baseMs * 100) / 100,
commitMs: Math.round(commitTime * 100) / 100,
id,
phase,
startMs: Math.round(startTime * 100) / 100,
ts: Date.now()
}
try {
appendFileSync(LOG_PATH, `${JSON.stringify(row)}\n`)
} catch {
// Same rationale as ensureLogDir — never crash the UI to log a sample.
}
}
export function PerfPane({ children, id }: { children: ReactNode; id: string }) {
if (!ENABLED) {
return children
}
return (
<Profiler id={id} onRender={onRender}>
{children}
</Profiler>
)
}
export const PERF_ENABLED = ENABLED
export const PERF_LOG_PATH = LOG_PATH