Skip to content

Latest commit

 

History

History
198 lines (143 loc) · 51.4 KB

File metadata and controls

198 lines (143 loc) · 51.4 KB

claude-session-namer - Design

2026-07-29

Problem

Claude Code names sessions on its own, but the names are vague ('New session', 'General coding session' - worse when a session starts with a screenshot) and never update once the work moves on. Sidebars and resume pickers fill with vague names, and sessions get lost. Existing tools each solve half the problem:

  • claude-rename writes real titles but names a session once, after the first exchange, and never revisits - long sessions outgrow their names.
  • claude-chat-namer stores names in its own sidecar metadata, so the native sidebar never sees them, and it derives names from the first user message - roughly what the vague defaults already are.

What this tool does

A zero-dependency Node CLI plus a Claude Code Stop hook that keeps session titles accurate for the life of the session:

  • Writes native custom-title records the Claude Code UI actually displays - no sidecar
  • Titles a session after the first real exchange, then re-titles when the conversation drifts
  • Backfills titles across existing untitled/vague sessions - recent ones by default (50 newest from the last 30 days), full history on --all
  • Manual rename, protect, list, and search commands
  • No API key - titles are generated via claude -p on the user's existing subscription, on haiku by default and sonnet if the user picks it

MIT license. Two distribution paths, both shipping: npm (npm install -g claude-session-namer, then claude-session-namer install) and a Claude Code plugin (see Plugin distribution below). A global npm install rather than npx: the hook wrapper embeds the absolute path to the CLI, and an npx path points into a cache directory that gets pruned.

Mechanism

Hook

install registers a Stop hook in ~/.claude/settings.json. On every Stop event the hook script:

  1. Reads the hook payload from stdin (session ID, transcript path)
  2. Exits immediately if CLAUDE_SESSION_NAMER_WORKER=1 is set in the environment - this is the recursion guard; our own headless title calls also fire Stop hooks, and the worker sets this var when spawning claude -p
  3. Spawns the worker as a detached background process and exits

The hook adds zero perceptible latency and produces no output.

Plugin distribution

The same hook is also registered by a Claude Code plugin, so a user can install the tool without npm and without running install. The repo is its own marketplace: .claude-plugin/marketplace.json at the root lists one plugin whose source is "./", which makes the plugin root the repo root and puts bin/ and src/ inside ${CLAUDE_PLUGIN_ROOT}. .claude-plugin/plugin.json carries the metadata, versioned in lockstep with package.json. hooks/hooks.json registers the Stop hook as command -v node >/dev/null 2>&1 || exit 0; exec node "${CLAUDE_PLUGIN_ROOT}/bin/cli.js" hook, timeout 15 - the same entry point, the same timeout, and the same silence as the wrapper install writes. The node guard replaces the wrapper's fallback to the installer's own node, which a plugin install has no equivalent of: nothing recorded a path at install time.

  • The entry pins "shell": "bash". Claude Code runs a shell-form hook command under sh on macOS and Linux, Git Bash on Windows, and PowerShell when Git Bash is not installed (hooks reference, read 2026-07-30). That last case is the problem: the command is POSIX throughout - command -v, >/dev/null, ||, exec - and PowerShell would fail to parse it after every Stop event, which is noise in a hook whose contract is silence when it cannot run. Pinning the shell is the whole guard; it is the documented default on macOS and Linux, so nothing changes there, and a Windows machine with no bash simply never starts the hook. Exec form (args) would also keep PowerShell out, but with no shell there is nowhere to run the command -v node check, so a missing node would turn into a spawn failure per Stop event on the platforms that do work. The plugin path takes no platform refusal of its own - hooks.json has no conditional mechanism, and there is no install-time moment to speak at.

  • suppressOutput is a hook output field in the current schema, not config. It costs nothing here: the hook writes nothing to stdout on any path, so there is no output to suppress.

  • Nothing about the worker changes. src/hook.js resolves the CLI as path.join(__dirname, '..', 'bin', 'cli.js'), so the spawn follows the package wherever it is unpacked, and state stays at ~/.claude/claude-session-namer (CLAUDE_CONFIG_DIR) regardless of install method. Switching between npm and plugin keeps every title claim, protection flag, and prefix count. The CLAUDE_SESSION_NAMER_WORKER=1 recursion guard is the first line of the hook and is unaffected by how the hook was registered.

  • skills/setup-sidebar-sync/SKILL.md is the plugin's one skill, in the directory a plugin scans by default - no skills field in the manifest, which for a marketplace entry whose source resolves to the marketplace root would replace that default scan rather than add to it. It walks the user through creating the sidebar sync routine (see Desktop sidebar setup below). Plugin-only: package.json's files whitelist keeps skills/ out of the npm tarball, and sidebar-setup is what npm users get instead.

  • bin/claude-session-namer is a shell wrapper over bin/cli.js. Claude Code adds an enabled plugin's bin/ to the PATH of Bash tool calls, so it makes backfill, rename, list and the rest runnable inside a session on a plugin-only install. It does not reach the user's own shell; npm is still what puts the command there.

  • Double-install is documented, not detected. Two registrations mean two workers per Stop event. The claim-before-append ordering is what keeps that benign rather than any lock: each worker re-loads state after its model call and claims its title in written before appending, so the second worker to finish sees the first one's title already recorded as ours. It appends its own title after it - a second custom-title record, which is a shape the app itself produces constantly - and the last record wins, the way it does for any re-title. Both strings are in written, so neither is ever mistaken for a human's later. Appends are a single appendFileSync well under PIPE_BUF, so they cannot interleave. The cost of a double install is a doubled model bill for the same title, not a broken session, and the README says to install one way, not both.

Desktop sidebar setup

sync-plan computes the diff the sidebar is missing and pushes nothing, because the only writer the app trusts is its own session-rename tool, held by an agent inside a desktop session. The routine that closes the loop is an hourly scheduled task in the desktop app - id session-title-sidebar-sync, cron 2 * * * * - whose prompt runs sync-plan and applies each JSON line through set_session_title, stopping on the first error and touching nothing else.

Setting that up is offered at onboarding rather than left in the README. On a TTY, install asks one question after the auth probe ("Do you use the Claude Code desktop app?") and on a yes prints the pointer; anything else, and a non-TTY install, print nothing extra and leave the existing output byte-identical. The prompt reader is an injectable seam alongside the probe's spawn, and it returns "no answer" rather than throwing on any failure - this is a pointer to an optional extra, never worth a crash on an install that already succeeded.

  • We never write the app's task registry. It lives under ~/.claude/scheduled-tasks/<id>/, and the app pairs it with state we cannot supply: the schedule, the working folder, and the tool permissions the user approves for the run. So both paths hand over instructions and let the app create its own routine, with the user's consent - the plugin skill for a plugin install, and a paste block from install or sidebar-setup for an npm one. Both come from one constant in src/commands.js, and a test asserts the skill file carries it verbatim.
  • The task prompt invokes the CLI by bare name. ${CLAUDE_PLUGIN_ROOT} resolves only in plugin components - hook and monitor commands, MCP and LSP config, and the plugin's own skill and agent content. A scheduled task's prompt is none of those: it lives in the app's task store, so the placeholder would reach the Bash tool as an unset shell variable and run node "/bin/cli.js". The bare name covers both installs instead. npm puts it on the shell PATH; an enabled plugin's bin/ is on the Bash tool's PATH in any session, and a scheduled run is an ordinary local session, so the plugin wrapper answers there. The prompt says to stop and report rather than guess at a path when the command is missing, which is what a task whose folder has the plugin disabled looks like.
  • The routine never passes --all. Sessions the user renamed in the app stay excluded, and pushing them is a dead end anyway - the app's rename API keeps the user's title and answers success-shaped.
  • The routine archives nothing, and clearing its run sessions is an interactive job. Every run leaves a session behind, and the routine looked like the obvious place to tidy them. It cannot be: an archive_session call from a scheduled run always raises "This tool requires explicit approval regardless of permission mode", for the run's own session and for ordinary ones alike, and no permission rule bypasses it, while set_session_title auto-approves in those same runs (observed live twice, 2026-07-30). A cleanup step in the task would stall on a prompt nobody is watching, so the task prompt asks for no archiving at all and says why, which is the part that keeps it from being re-added. Re-tested 2026-08-14 on 2.1.220, after the permission system gained a defer PreToolUse decision and per-task stored approvals: with the archive rule present in permissions.allow, a live scheduled run's archive_session call still sat on its approval unanswered past ten minutes. The constraint stands. One open question for a future sweep: the app now stores approvals granted during a run on the task itself and re-applies them to later runs, so a single watched run that approves an archive might make unattended cleanup possible after all - untested whether that store outranks this gate.
    • The cleanup itself ships in the skill and the paste block, as something the user asks any interactive desktop session to do, at one confirmation dialog per archived session: on current builds the app confirms every archive_session call regardless of mode or allow rules, with 'Allow once' as the only approval (seen live 2026-08-14 on 2.1.220 - a bulk sweep raised one dialog per session until it was stopped). The pre-approved rules quiet the lookup steps only. Prior runs are identified by their scheduled-task linkage, never by title. Title matching is what the first version did, and a live machine showed why it cannot work: a run session is an ordinary session, so this tool's own Stop hook titled it within a couple of replies, the exact-title match stopped landing, and the run sessions piled up - 8 counted in one day. The rule is instead: list the app's sessions, read each candidate's linkage through the session-detail tool (get_session), archive only the ones linked to this task's id, never the current session, and never a session whose linkage could not be read. list_sessions, get_session and archive_session stay in the pre-approved set for exactly this.
  • The routine's own run sessions are never titled. Their first user message is the task prompt itself, which makes them recognizable the same way the transcripts of our own headless calls are: titler.SIDEBAR_TASK_SIGNATURE is how the prompt opens, and the template is built from that constant so the two cannot drift. It is the sentence opening rather than the whole first line, because a routine created weeks ago carries the prompt text it was created with, frozen in the app's task registry, and only the tail of that line has ever been reworded. A session opening with it gets no title, no drift check, and no done marker, on the hook path and in both sweeps (see Titling decisions). That saves a model call per scheduled run, and it leaves the run sessions uniformly named until somebody clears them, rather than scattered under titles this tool invented for its own automation.

Worker

The worker owns all logic:

  1. Loads per-session state from ~/.claude/claude-session-namer/state.json
  2. Decides whether to act (see Titling decisions below); most invocations exit without an LLM call
  3. Builds a compact excerpt from the transcript JSONL - recent user and assistant text turns, truncated per turn and overall
  4. Calls claude -p --model <model> --no-session-persistence (with CLAUDE_SESSION_NAMER_WORKER=1 in env) using a strict prompt that returns either KEEP or a title. The title shape is enforced on the way back, not just asked for: parseResponse reads an answer that is not title-shaped - a list or heading marker opening the line, markdown emphasis, first-person narration, or a line past double the 45-character cap - as KEEP, because the failure this catches is the model answering the conversation instead of naming it, and truncating that prose to fit once wrote a plan fragment in as a session's title (2026-08-24). The shape test is shared with sync-plan as titler.isTitleShaped, so what the parser refuses to accept the plan also refuses to forward. The flag (Claude Code 2.0.63) keeps the call from filing a session of its own. A CLI older than the flag rejects it at argument parsing - exit 1, error: unknown option on stderr, before auth or any model call - so titler.spawnClaude retries bare on exactly that failure and remembers the answer per process: a backfill pays the one failed attempt once, not per session. The install probe goes through the same wrapper
  5. Appends a {"type":"custom-title","customTitle":"...","sessionId":"..."} record to the session transcript file - a single atomic append
  6. Records what it wrote in state

Model

The titling model is a config field (model, default "haiku"), set with claude-session-namer config model haiku|sonnet. Haiku is the default because the job is an eight-word phrase, and it is the cheap end of a call that fires several times per session; sonnet is there for users who would rather pay about 3x a call for a better read of a messy conversation. config model accepts those two names and nothing else. A free-form model string would reach claude -p on every Stop event, and a name that doesn't resolve fails the call into a hook that is silent on failure by design - titling would simply stop, with nothing said and nowhere to see it. backfill --model <m> stays unrestricted and overrides the setting for that run: it is one sweep the user is watching, so a bad name reports itself immediately. Everything the worker asks for uses the configured model - first title, drift check, and restyle alike, because a setting that covered only one of the three would leave most calls on the old model. The install-time auth probe stays fixed on haiku: it is a connectivity check, not a title.

An unsupported value hand-written into config.json reads as haiku rather than being passed through, the same way a missing prefix reads as true.

Titling decisions

  • Our own prompts are refused outright. Every prompt this tool sends becomes the first user message of some session: the headless title and done calls file transcripts of their own on a CLI older than 2.0.63 (newer ones run under --no-session-persistence and file nothing - see Worker), and each scheduled run of the sidebar routine opens with the task prompt we ship. titler.OUR_PROMPT_SIGNATURES is the one list of those opening lines and titler.isOurOwnPrompt the one test, shared by the worker and both sweeps so they cannot disagree. A session that opens with one gets no title, no drift check, and no done marker. This sits ahead of every gate, protection included, because there is nothing about our own automation worth a model call.
  • First title: when a session has at least one real user/assistant exchange and no non-vague title.
  • Drift re-title (growth-gated, two triggers): re-check when either measure of growth has moved since the last check - the session's user-turn count has at least doubled and gained 4 turns, or the transcript's total record count has at least quadrupled and gained 80 records. Calls scale with the log of session length on both measures: a 100-turn session gets roughly 5 checks lifetime, and a 400-record session with two prompts gets a couple rather than none. The check sends the current title plus a recent excerpt; the model answers KEEP or supplies a replacement.
    • The turn trigger carries an absolute floor alongside the doubling, and both halves have to hold: turns >= lastCheckTurns * 2 && turns >= lastCheckTurns + 4. Early on, a doubling is one or two new prompts - 1 turn to 2, or 2 to 4 - which is ordinary back-and-forth rather than a change of subject, so without the floor a young session would re-check almost every Stop event. The floor stops mattering as soon as the doubling is the larger step, at 4 turns and up.
    • The record trigger exists because the turn count cannot see agentic work: a session that runs for hours on three prompts drifts as far as any other, and the turn gate would never look at it again. Quadrupling is what keeps that log-scaled; the 80-record floor is the other half of the condition, because on a short session a quadrupling is a few dozen records of ordinary back-and-forth rather than a change of subject.
    • The record baseline (lastCheckRecords) arms rather than fires on first sight. State written before the field existed carries no baseline, and a transcript that shrank measured something that no longer exists - reading either as zero would treat a session's whole history as growth and re-check every long session at once on upgrade. Both cases take the current size as the baseline and fire nothing. Every look moves it, including a KEEP, or an agentic session that has stopped drifting would re-ask on every Stop event.
  • Format restyle (growth-gated): the prefix setting applies to every title the tool manages, not just the next one it writes. A title that is accurate but in the wrong format - a bare phrase with prefixes on, a bracketed prefix with them off - is sent back to the model in restyle mode: rewrite it into the required format, preserve its meaning, never answer KEEP. With prefixes on the excerpt goes along, used only to pick the prefix; with prefixes off the transform is mechanical - strip the bracket, keep the phrase - so the prompt carries no excerpt at all. The check runs on the same growth gate as the drift check - either trigger opens it - and takes precedence over a drift check, because a drift check can answer KEEP and that would leave the format wrong for good; the next drift check, one growth step later, is where meaning gets re-derived. Vague titles have nothing worth preserving and stay on the first-title path.
    • Only the two hard protections sit upstream of this check: the manual flag rename and protect set, and the app store's 'user' marker. The third protection below - the KEEP-biased personal-label instruction - lives in the drift prompt, and restyle mode strips every KEEP rule with it. So a title somebody typed by hand that carries neither marker gets reshaped into the format rather than spared: Revisit Monday with prefixes on comes back as something like [Emails] Revisit Monday. The meaning survives, the shape changes. protect is the way to hold a title exactly as typed.
    • The check takes the session's project signal with the setting: with prefixes on, a bare title on a session that belongs to no project directory already conforms and is never sent for a prefix it could only borrow (see Sessions that belong to no project below). Inside a project nothing changes.
    • backfill passes a force flag that bypasses this gate, and only this gate. Sweeping history after flipping the setting is the whole point of the setting being a contract, and most swept sessions are finished: they carry a drift baseline from an earlier check and their turn count will never grow past it, so the gate on its own would never open again and the sweep would converge nothing. Drift rechecks are not forced - those still wait for the session to grow, or a sweep would re-derive meaning on every session at a model call each. The gate closes as soon as a title conforms, so a second sweep over the same history costs nothing.
  • Protection is never inferred from the title record. Live-data testing killed the original assumption here. The desktop app files its own auto-titles as custom-title records - the same record type a hand rename produces - and re-asserts the current title every ~14 transcript lines. On the author's machine, 68 of 69 titled sessions carried a custom-title and only one carried an ai-title. So the record type says nothing about who wrote the title, and treating a foreign custom-title as a human's manual-locked nearly every session on first sight, which is the one thing that defeats the whole tool. What protects a title instead:
    • rename and protect set the manual flag in state. That is permanent until unprotect, and it is the guarantee that holds on every platform.
    • The app's own session store marks a title the user typed (see below). Those are skipped outright.
    • Where neither applies, the drift check is KEEP-biased: the prompt tells the model to output KEEP when the current title reads like a deliberate personal label (a person's name, a date, a note like 'Revisit Monday') rather than a description of the work. That bias is in the drift prompt only. A format restyle strips every KEEP rule with it, so an unmarked personal label in the wrong shape gets reformatted rather than spared - its meaning intact, the format applied.
  • App-side renames are detected, via the app's own store. The Claude desktop app keeps one JSON file per session under ~/Library/Application Support/Claude/claude-code-sessions/<uuid>/<uuid>/local_*.json, carrying cliSessionId (the transcript session id), title, and titleSource - 'user' when someone typed the name in the app UI, 'auto' when the app generated it. That marker is the only place on disk the distinction survives; the transcript record types don't carry it. Verified against 233 live session files: 200 auto, 27 user, 6 with the field absent (older app builds - absent reads as auto, since a rename has always been recorded explicitly). src/appstore.js walks the store read-only and returns 'user' | 'auto' | null, and the worker skips any session marked 'user' before it looks at titles or growth at all. list shows those sessions with a trailing [renamed in app].
    • The trade-off is a stale title CLI-side: a session marked 'user' is never written to again, so it keeps whatever title its transcript already carries, and if the name typed in the app never reached the transcript the CLI keeps showing the old or vague one (confirmed live - an app-renamed session still reads as 'New session' in list). rename is the way out, since it writes the name into the transcript and locks the session.
    • Nothing about the lookup is cached into our state. The store is consulted live on every run, so a marker that changes - the user renames again, or the app rewrites its own record - takes effect immediately rather than leaving a frozen copy behind.
    • Every read is best-effort: a missing store, an unreadable directory, or a half-written JSON file reads as "no signal" and the session goes through the normal flow. A missing store is the normal state on Linux and Windows, and on any machine that has only ever run the CLI - which is why protect remains the cross-platform guarantee.
    • The store is the app's private data. The tool only reads it, and only for this one field.
    • The protection holds at the app's API layer too: the session-rename tool refuses to change a 'user'-titled session, answering with success-shaped text while keeping the user's title (verified live 2026-07-29). That closes the door on any tag-only restyle of app-renamed sessions through the API - the only writer the app trusts for those sessions is the user in the UI.
  • The gate measures both kinds of growth. Counting user turns alone missed agentic sessions entirely - a handful of prompts and hours of tool calls never doubles a turn count, so those sessions re-checked once and never again, and a correction typed mid-session went unread for the life of the session. The record trigger above covers them. Neither trigger costs anything on a session that is not growing, which is what keeps a quiet session free.
  • App auto-titles are replaceable, by design. Replacing them is the product. Known-vague titles ('New session', truncated first-message titles) are fair game for the same reason.
  • Live sessions get the app's registry title re-asserted into the transcript. Observed on a real session 2026-07-29, on the then-current desktop build: the worker wrote its title record at the first user turn, the app's own auto-titler wrote a different custom-title record after it, and then re-asserted that same app title 11 more times as the session grew. What the app writes into the transcript is the title held in its registry, so a record we append while a session is active gets written over rather than picked up. Transcript and registry then agree on the app's name, the plain diff is empty, and the session sits on the app's title even though the tool titled it.
    • The worker's mid-generate guard treats displacement as benign. After the (up to 90s) model call the worker re-reads the transcript and abandons its title if a different, non-vague custom-title has appeared - correct when a human renamed the session mid-flight, wrong when the new title is the app re-asserting its own. On a displaced active session that abort fired on every eligible Stop event: a model call spent, nothing appended, nothing recorded, and state frozen at the first check while the session ran on (observed live at lastCheckTurns 1 against 200+ transcript records). The guard now proceeds - appends and records - when the arriving title is the registry's own title for the session and the registry marks it auto (absent reads as auto). That is the same displacement fingerprint sync-plan uses, shared as appstore.isDisplaced so the two cannot drift apart. A title the registry does not hold is somebody else's write and still aborts, and the two hard protections are untouched: the manual flag and titleSource: user are re-checked on fresh data first and abort whatever the registry says. No store, or no matching row, means no signal and the conservative abort stands.
    • sync-plan detects that displacement and proposes our own last title again, keyed by the app session id like any other line (see the command below). The push goes through the app's session-rename API, which remains the only external writer of the registry, and the app's next re-assertion carries our title into the transcript - convergence rather than a write war.
    • An earlier build was observed adopting the newest transcript record instead, which is what this document asserted until now. The behavior is version-dependent, so the mechanism stays best-effort in the same way every other read of an undocumented format here is: if a later build adopts again, the displacement branch simply never fires, because its trigger is the registry and the transcript already agreeing on a title that isn't ours.
    • Backfill skips sessions touched in the last 10 minutes for an unrelated reason: those sessions have a Stop-hook worker of their own, and a sweep would race it.

Done marker

A session whose work has stopped can carry a leading on its title, so a sidebar of thirty sessions reads at a glance: the ones without a checkmark are the ones still open. Off by default (doneMarker: false), because it is an extra model call per finished session and a preference rather than a fix for anything. config done-marker on|off sets it.

  • Marker grammar. The marker is exactly one checkmark and one space, prepended to an otherwise format-conforming title: ✓ [API] Rate limiter fix. It is a prefix on a title, never part of one. matchesFormat strips it and judges what is left, so a marked title conforms exactly when its core does, in either prefix mode - the marker is orthogonal to the prefix setting. The 45-character cap is a cap on the core, so a marked title runs two characters longer.
  • The model never sees it. Every prompt is built from the core and every comparison is made against the core; the marker is re-applied after. A drift check or a restyle handed the marked string could edit it, drop it, or echo it into a title that was never judged finished, and parseResponse strips a leading checkmark from an answer for the same reason. So a restyle of a marked title in the wrong format comes back reformatted and still marked, and a KEEP writes nothing at all.
  • Both strings are recorded. Marking appends the marked title and records the marked and unmarked strings in the session's written list, plus a done flag. That is what keeps every string-matching mechanism working unchanged: displacement detection, the worker's mid-generate arrival guard, and sync-plan all compare exact strings, and a marked session has two of ours in play. sync-plan pushes a marked title like any other, and converges the same way - once the registry holds it, the line stops being emitted.
  • Trigger: the sweep-done command, never the hook. The Stop hook fires while a session is alive, which is the one moment it cannot be over. The sweep walks the same scoped set as backfill (50 newest, 30 days; no --all, because marking a session from last year as finished tells nobody anything) and skips anything touched in the last 2 hours. That bar is re-tested against the file's own mtime immediately before each judgment and again after it: the cutoff is computed once, before the loop, but every candidate costs a model call of up to 90 seconds, so a candidate reached late in a sweep was screened on minutes-old data and a session the user picked back up mid-sweep could still collect a checkmark. A session that moved is skipped with nothing recorded - the judgment was about a transcript that no longer exists. That bar is deliberately twelve times backfill's ten minutes: ten minutes of quiet only says nobody is mid-reply, where this asks a model whether the work is over, and a checkmark on a session somebody is about to come back to is the one visible way it can be wrong. It also skips vague titles, titles this tool never wrote (nothing of ours to mark), manual and app-user sessions, and sessions already marked.
  • Cost is bounded at one judgment per session, not one per sweep. Each candidate costs one call on the configured model, answering DONE or ONGOING from the tail of the transcript. The size the session was judged at is recorded as doneCheckedRecords, so a sweep over an unchanged session asks nothing, and a marked session is skipped on the flag. A finished session therefore costs exactly one call for the rest of its life. The checkpoint counts the marked record the sweep itself appended, or the worker would read our own write as growth. Every later tool-owned append to a still-marked session moves it by exactly one for the same reason - a restyle after a prefix flip is the case that found this - and by one rather than up to the current size, so a record somebody else added stays just as visible. Anything unclear reads as ONGOING: a missed marker costs a re-judgment, a wrong one is visible.
  • A marker is only ever carried onto a title of ours. sweep-done marks nothing but a title this tool wrote, so a checkmark on a core we never wrote came from somewhere else and says nothing we can vouch for. A restyle or a drift re-title of such a session writes the new title bare. Re-applying the marker would put a checkmark on a string this tool had just derived and nothing had judged finished - the same minting parseResponse refuses when a model decorates its answer with one - and it flapped besides: the marked core would then be ours with no checkpoint behind it, so the resume path below stripped the marker again on the very next Stop event, two records for a checkmark that appeared and vanished. A marker of ours on a title of ours is untouched by this: it still survives a reformat.
  • Resume strips it, mechanically. When the hook fires on a session whose title is marked and whose core is ours, and the transcript has grown past doneCheckedRecords, the worker appends the unmarked core, clears done, drops the checkpoint, and carries on through the normal pipeline with the growth gates unchanged. No model call: new records are the whole answer to whether the work is still over, and the core is a title we already wrote and already claimed. The test is the transcript's title rather than the state flag, so a marker outlives neither a lost flag nor a flag outlive a stripped marker. The two hard protections stay upstream of all of it.
  • sweep-done is a no-op when the setting is off, printing one line and exiting 0 rather than treating it as a usage error - the hourly sidebar routine can then call it unconditionally.

Title format

[Prefix] Short phrase, hard-capped at 45 characters. The prefix is optional per user: a config file (~/.claude/claude-session-namer/config.json, default {"prefix": true, "model": "haiku"}) controls it, toggleable via claude-session-namer config prefix on|off or install --no-prefix. With prefixes off, titles are the bare phrase. Prefixes are free-form but normalized: the prompt includes the user's previously-used prefixes with an instruction to reuse one when it fits and coin a new one only for a genuinely new workstream. Seen prefixes and counts live in state.

The setting is a format contract rather than a default for new titles. titler.matchesFormat(title, usePrefix, inProject) is the test - a bracketed prefix of 1-25 characters followed by a phrase in prefix mode, a title that doesn't open with a bracket in bare mode - and any title the tool manages that fails it gets reformatted with its meaning preserved (see Format restyle above). Reformatting rather than regenerating is the point: the old title was usually right about the work, so re-deriving it would risk the meaning to fix the shape. Renamed, protected, and app-renamed sessions are exempt.

Sessions that belong to no project

A mandatory prefix has nothing to name on a session that belongs to no project, and the failure was reported from a real machine: a session run from the home directory, about which cat house to buy, came back as [Domestique] Cat house comparison - Domestique being an unrelated website project. The prefix was required, the reuse list was in front of the model ranked by raw count, so borrowing was the only move available. Frequency ranking compounds it: a borrowed prefix climbs the list and catches more strays.

  • Where a session ran is on disk already. A transcript lives at <projectsDir>/<encoded-cwd>/<id>.jsonl, and that directory name is the session's working directory, with every non-alphanumeric character replaced by a dash. paths.projectSignal reads it: the encoded home directory is no project (unless home-is-project is on - below), the encoded OS temp dir (where our own headless calls land) is no project, and anything else is a project. It is a pure string read - no filesystem walk, no app store, nothing macOS-only - so it costs nothing on the hook path and answers the same on a machine that has only ever run the CLI. Anything unparseable reads as no signal, and no signal reads as no project: a bare title is the safe answer when we cannot tell, because the wrong prefix is the failure being fixed.
    • It also carries a hint: words for the directory, for a prompt to name it by. The encoding is lossy - / and - both became - - so the hint takes the first segment below home as the enclosing folder and leaves the rest as one name (-Users-x-projects-claude-session-namer reads back as projects/claude-session-namer), which is the shape almost every project directory has. A deeper path renders flatter than it is. It is never a path anything opens.
  • The prompt, prefixes on, no project: no reuse list at all. Re-wording the instruction was the obvious fix and the wrong one - the borrowed prefix was borrowed because it was in front of the model, ranked. A name the model never sees cannot be borrowed. The output format allows either shape, and two rules say why: the session belongs to no project directory and must never borrow a prefix from other work; if no prefix comes out of the conversation itself, the phrase goes out with no prefix, because a bare title beats a wrong prefix. A prefix the model coins from the conversation is still fine - what is ruled out is acquiring one from elsewhere.
  • The conformance check takes the same signal. With prefixes on, a bare title on a no-project session conforms and is never restyled - on the hook path and on backfill's forced path, which is the one that reaches finished sessions and would otherwise have walked a user's history fitting borrowed prefixes to it. A bare title in a project still restyles, exactly as before. Prefix-off behavior is untouched everywhere: that contract is about brackets, and where the session ran has no bearing on it. A malformed title ([Emails] with no phrase) is still malformed in either arm, so a no-project session can still reach a restyle - and the restyle prompt drops the reuse list the same way. The done marker composes unchanged: the marker comes off, the core is judged against this rule, the marker goes back on.
  • home-is-project is the opt-out, off by default. The rule reads a stray home-directory session right and a user whose whole workspace is the home directory wrong: for them it takes prefixes off every session they have, which is the opposite of what the prefix setting asked for. claude-session-namer config home-is-project on (homeIsProject in the config file) makes the home directory an ordinary project directory - inProject true, the encoded home dir as the project dir - so prefix ranking, the reuse list, the "prefix already established here" rule, and the conformance check all treat it like any other directory. Everything above holds unchanged with the setting off, which is what an absent or hand-edited non-boolean field reads as, the same way an unsupported model reads as haiku. The encoded temp directory stays no project either way: those transcripts are echoes of this tool's own headless calls on an older CLI, and nothing a user decides about their home directory should put a title or a prefix on one. The worker reads the setting and hands it to paths.projectSignal as an option, so the classifier stays a pure string read with no config access of its own.
  • The prompt for a project session gains annotations instead. Each prefix renders as Name (from <dir hint>; e.g. "<sample title>"), so the model can turn one down rather than picking the most frequent. Prefixes last used in this session's own directory rank ahead of bigger counts from elsewhere, and when there is at least one, a single rule line says the session ran in that directory and to prefer its established prefix unless the conversation clearly is not that work. Every piece of an annotation is untrusted input - it came off a title record or the state file - so all of it crosses sanitizeForPrompt, and the line as a whole is bounded, since fifteen annotated prefixes would crowd out the conversation the title is supposed to come from.

CLI commands

  • install / uninstall - register/remove the Stop hook in ~/.claude/settings.json (surgical JSON edit, preserves everything else in the file). After a successful registration, install probes the titling path with one claude -p ping --model haiku call (30s timeout, CLAUDE_SESSION_NAMER_WORKER=1 so the hook it just registered ignores the probe's own session). A failing probe prints a warning naming the case - CLI not on PATH, or a failed call with the CLI's own stderr line - and the fix. The install still succeeds and still exits 0: the probe only makes a titling path that would fail silently forever visible at the one moment the user is watching. The same posture, one step earlier and absolute, covers the platform: install checks process.platform before it reads settings or writes anything, and on win32 prints three lines to stderr and exits non-zero with nothing installed - the wrapper is a /bin/sh script, so a Windows install would leave settings.json claiming titling is on while the hook could never fire and would never say so. The platform is an injectable seam alongside the probe's spawn. Every other command is untouched, uninstall included: they are pure Node, and a removal should work wherever it is run. After the probe, an interactive install asks the desktop-app question (see Desktop sidebar setup). uninstall does not probe.
  • backfill [--dry-run] [--model <m>] [--project <path>] [--since <days>] [--limit <n>] [--all] - sweep the project dirs under ~/.claude/projects/ and title vague/untitled sessions. Scoped by default to what a user still recognizes in their sidebar - the 50 newest sessions from the last 30 days - with the scanned scope printed above the summary. --since widens the window, --limit changes the cap, and --all drops both for full history (mutually exclusive with --since/--limit). A sweep also reformats any title that doesn't match the current prefix setting, whatever the session's drift baseline says (see Format restyle). Dry-run prints planned titles without writing; throttled to stay clear of rate limits
  • rename <session-id> "title" - set a title by hand, marked manual. Claimed in state and saved before the record is appended, the same crash ordering the worker keeps: a failed save then leaves the old title rather than a hand-typed one the next worker run would read as unclaimed and reformat
  • protect <session-id> - mark a session manual without touching its title, so whatever it is named now stays
  • unprotect <session-id> - drop the manual mark and let drift re-titling resume
  • list [--project <path>] - sessions with titles, newest first; protected sessions carry a trailing [protected], sessions renamed in the desktop app a trailing [renamed in app], and a session can carry both
  • search <query> - match against titles and transcript content
  • sidebar-setup - print the prompt that sets the sidebar sync routine up, for pasting into a desktop session. Unconditional: no terminal check, no platform check. Same text the bundled skill uses, from one constant, so the two cannot drift
  • sweep-done [--dry-run] [--project <path>] [--since <days>] [--limit <n>] - put the done marker on the sessions whose work has stopped (see Done marker above). Same scope as backfill and no --all; skips anything touched in the last 2 hours; one model call per candidate, once per session size. A no-op with a one-line message when doneMarker is off, so the scheduled routine can call it unconditionally. --dry-run prints what would be marked and writes nothing
  • sync-plan [--all] - print, as JSON lines, the sessions whose transcript title differs from the title in the app's own registry ({sessionId, currentTitle, newTitle}, keyed by the app's session id). The sidebar reads that registry rather than the transcript, so a title written here never reaches it; this command computes the diff and writes nothing, leaving the push to a scheduled Claude session or any agent holding the app's session-rename tool. It emits one more case, in the same line shape: a session the tool titled whose transcript title is no longer one of ours, where the registry holds that same displacing title (the app's auto-titler re-asserting itself, above) or already holds a title of ours from an earlier push. There the proposed title is our newest, not the transcript's. Renamed and protected sessions are exempt, titleSource: user is exempt, and a displacing title the registry does not share is somebody else's write - those stay on the plain transcript-versus-registry diff. Once the registry carries our title the line stops being emitted, whether or not the transcript has caught up, so a re-push is idempotent and cannot oscillate. User-renamed sessions are excluded unless --all is passed - and --all is visibility only: the app's rename API refuses an agent rename of a titleSource: user session, returning success-shaped text while keeping the user's title (verified live 2026-07-29 against the then-current desktop app, whose rename tool now states the behavior in its own response). Printing those lines shows the diff; no external writer can apply it. A row whose proposed title is not title-shaped is refused outright, on both the plain and the displaced path - the same titler.isTitleShaped predicate parseResponse applies to a model answer, done marker stripped first like every format check. The titler refusing prose at generation is the fix; this is defense in depth for a fragment already standing in a transcript or in state (the 2026-08-24 field case, which an hourly sync would otherwise re-propose forever), so a corrupted title stops at the plan instead of reaching the sidebar.

State

~/.claude/claude-session-namer/state.json:

  • Per session: last-check user-turn count, last-check transcript record count, titles written by the tool, manual flag (set only by rename and protect), done flag and the record count done-ness was last judged at (doneCheckedRecords)
  • Global: seen prefixes, each { count, dir, sample } - how often the prefix has been used, the encoded project dir it was last used in, and the most recent title carrying it. dir is what the ranking matches on and sample is what the prompt shows; a write that doesn't know where it is (a hand rename, a session belonging to no project) keeps the dir the entry already had rather than erasing it.
    • Migration: a bare number keeps working forever. Every version before this one wrote {"Emails": 4}, so a number reads as a count with nothing else known about it, and the next write upgrades that entry in place. Nothing rewrites the file on load, and an entry of any other shape - a string, a null, a hand-edited array - reads as no entry rather than reaching a prompt as an object.
  • Corrupt or missing state degrades gracefully - worst case a session is re-checked earlier than needed

Policy and fragility posture

Verified against current Anthropic docs (2026-07-29):

  • Headless claude -p is a documented, sanctioned interface for programmatic use; usage counts against the user's subscription normally. No policy restriction on local automated invocation. This is distinct from the prohibited pattern of extracting subscription OAuth tokens for third-party apps - this tool only invokes the official CLI locally as the user.
  • The transcript JSONL format is explicitly internal and can change between releases. Writing the custom-title record mimics a record type the app itself writes, but it is unsupported territory. Failure mode is benign: an append-only record the app either recognizes or ignores; a format change means titles stop applying until patched, never session corruption.
  • A title read off a transcript is untrusted input on the way into a prompt. A custom-title record is a line in a file the desktop app and any other tool also write, so the drift, restyle, and done-judgment prompts flatten it - and the prefix list, which is parsed back out of titles - to a single line, control characters collapsed to spaces and the length capped at the format cap plus the marker's two characters. Interpolated raw, a newline followed by an imperative sentence reads to the model as a rule of the prompt rather than as the session's name. titler.sanitizeForPrompt is the one place it happens, and it is prompt-side only: what gets written to a transcript is unchanged.
  • Mitigation as built: the record shape is hardcoded - the same custom-title type the app itself writes - rather than feature-detected from records already in the transcript. Feature detection was considered and dropped: a session with no title record yet has nothing to detect from, which is exactly the case that matters. If the format changes, titles stop applying until the writer is patched. The README states plainly that the tool touches unsupported internals and may break on a Claude Code update.

Testing plan

  • Unit: excerpt builder, vague-title detection, state transitions, settings.json hook surgery (round-trip preserves unrelated keys)
  • Integration on the author's machine: live session gets titled after first exchange; drift re-title fires on a genuinely drifting session; manual rename survives drift; recursion guard holds (no hook storm from worker calls)
  • Desktop app check: confirm sidebar reflects titles live or on reload (unknown until tested)
  • Concurrency: append to an actively-written session file under load, verify no interleaving
  • Backfill dry-run over the author's full history before any real backfill

Rollout

  1. Private repo at github.com/sturimcode/claude-session-namer
  2. Build, test locally against real sessions, run and verify backfill
  3. Public: README (install, what it does, unsupported-internals caveat, cost note), npm publish, repo public
  4. Plugin: the manifests ship in the same repo, so making the repo public makes it installable as a plugin with no separate release step. claude plugin validate . is the pre-publish check. Submitting to claude-community is optional and independent of the npm release

Ongoing maintenance

Recurring research sweep (monthly, set up as a scheduled task once the tool ships):

  • Claude Code changes: scan release notes and docs for transcript-format changes, a public session-title API, or new hook capabilities. A format change means patching the record writer; a public title API means replacing the JSONL append entirely.
  • Competing tools: re-check claude-rename, claude-chat-namer, and search for new entrants. Fold in genuinely better ideas; note in the README how this tool differs.
  • Each sweep produces a short note in docs/research/ with date, findings, and any resulting issues.

Later (V2+)

  • More plugin skills (/claude-session-namer:backfill and friends). setup-sidebar-sync is the first one and covers the step a user cannot do from the CLI at all; wrapping commands that already work on the Bash PATH is a smaller win and can wait for a reason
  • Optional API-key mode for users who prefer metered billing over subscription usage
  • --concurrency <n> for backfill: a few parallel CLI calls would cut a sweep several-fold (the per-call cost is CLI startup, not the model). Held out of v1 on purpose - the sequential throttle is rate-limit politeness, and how the subscription layer treats parallel headless calls is untested. Still the only startup lever there is: --bare (2.1.81) looked like the fix and is not - bare mode never reads OAuth or the keychain, so it cannot authenticate a subscription install, and its measured 7x was a fast login failure (docs/research/2026-08.md, 2026-08-14 addendum). Do not re-propose it while that auth rule holds
  • --no-session-persistence on the worker's claude -p calls: shipped 2026-08-14 - every headless call (worker and install probe) goes out with the flag and falls back bare when the CLI rejects it as unknown (see Worker). The flag landed in Claude Code 2.0.63 (2025-12-09; found by binary-searching the npm tarballs, the changelog never mentions it), and the fallback was proven against a real 2.0.62: the rejection is exit 1 with error: unknown option '--no-session-persistence' on stderr, before auth or any model call. The sweep exclusions stay - a CLI older than the flag still files echo transcripts
  • Swap the JSONL append for a public title API if Anthropic ships one