Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +152,18 @@ For each discovered session, in order:
3. **No-activity short-circuit** — if `last_active` is unchanged since we last
fully evaluated it (`seen_active` in state), skip without re-reading the
transcript. Cheap; keeps the poll loop light.
4. **Substance** — skip if there are fewer than `min_user_messages` non-trivial
4. **Namer-artifact** — skip if the current title or a user message is the
naming prompt itself (`util.is_namer_artifact`). CLI namer calls and Claude
Code's built-in tab-titler can leak into the session list; feeding them back
into the namer creates more junk sessions.
5. **Substance** — skip if there are fewer than `min_user_messages` non-trivial
user messages (acknowledgements, slash-commands and harness/tool noise are
filtered out in `util.is_trivial` / `is_noise`).
5. **Unchanged content** — hash the recent transcript (`util.signature`); if it
6. **Unchanged content** — hash the recent transcript (`util.signature`); if it
matches the hash tied to the title we last wrote, skip. This makes runs
idempotent and **respects titles you edit by hand** — until the conversation
moves on.
6. Otherwise generate a title, shape it (`util.shape_title`), and write it if it
7. Otherwise generate a title, shape it (`util.shape_title`), and write it if it
differs from the current one.

### Baseline timestamp (v0.6.0+)
Expand Down Expand Up @@ -206,7 +210,8 @@ baseline they didn't want to commit to.
next pass to clobber a hand-edited title.
- **Titling via your own logged-in CLI by default.** The default `auto` namer
reuses the `claude`/`codex` CLI you're signed into (no API key); a short excerpt
goes to that provider. Set `namer = "heuristic"` for a fully offline run. See
goes to that provider. Those CLI calls are ephemeral (no extra session in your
Claude Code / Codex list). Set `namer = "heuristic"` for a fully offline run. See
[SECURITY.md](SECURITY.md).

## Adding a tool
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ All notable changes to this project are documented here. Format loosely follows

## [Unreleased]

## [1.0.1] - 2026-08-19

### Fixed
- **CLI namers no longer flood Claude Code / Codex with junk sessions.** The
default `auto` / `claude` / `codex` namer shells out to `claude -p` or
`codex exec` to write a title. Those CLIs were persisting the call as a
*real* session whose title is the naming prompt itself — `You name
coding-assistant sessions…`, or Claude Code's own `Generate a concise tab
title for this coding chat…`. After the idle window, rename treated those as
new work and called the namer again, so the session list grew with every
pass (and with every project you ran from).
- `claude`: `--bare --no-session-persistence` plus
`CLAUDE_CODE_SKIP_PROMPT_HISTORY=1` (the flag is a no-op on some Claude
Code versions; the env var still suppresses transcripts)
- `codex`: `--ephemeral --skip-git-repo-check`
- both run in an isolated scratch directory, not the user's project
- sessions whose title or user text looks like a namer / auto-title prompt
are skipped, so leftover junk cannot retrigger the loop
- older CLIs that reject the new flags are retried without them

The Codex namer still defaults to **`gpt-5.3-codex-spark`**. Default
`namer = "auto"` still prefers the `claude` CLI (Haiku) when installed, then
Codex Spark.

## [1.0.0] - 2026-06-02

### Changed — breaking
Expand Down
8 changes: 6 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ This makes the whole thing **idempotent** and **safe to run continuously**.

**Where the title comes from.** By default rename shells out to the `claude`
(or `codex`) CLI you're already logged into — `claude --model haiku -p "…"` — so
titles are real LLM summaries of the conversation, with no API key. No CLI
titles are real LLM summaries of the conversation, with no API key. The call is
ephemeral and does not leave an extra session in your chat list. No CLI
installed? It falls back to the offline heuristic.

**Safe by default on existing machines.** When you install rename, the daemon
Expand Down Expand Up @@ -234,7 +235,10 @@ back to a fully-offline heuristic if neither is installed. You never paste a key
| `openai` | OpenAI API directly, with **your own key** | `api_key` or `OPENAI_API_KEY` |

Out of the box — nothing to configure, no key to paste — you get LLM-quality titles
using credits you already have. Prefer zero cost / fully offline? Set `namer = "heuristic"`.
using credits you already have. `auto` **prefers the logged-in `claude` CLI (Haiku)**
and only uses `codex` (default model id **`gpt-5.3-codex-spark`**) if `claude` is not
installed. CLI naming calls are ephemeral and must not show up as extra sessions in
Claude Code / Codex. Prefer zero cost / fully offline? Set `namer = "heuristic"`.
Want to force Codex? Set `namer = "codex"` and change `[codex] model = "..."`.

**Bring your own key.** Want to use your own Anthropic/OpenAI account instead of a
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ $ rename search postgres --content # 连消息正文一起搜,带匹配片段

**标题是怎么来的。** 默认 rename 会调用你已经登录的 `claude`(或 `codex`)命令行
——`claude --model haiku -p "…"`——所以标题是对话的真实 LLM 总结,且无需 API key。
没装 CLI?就退回离线启发式。
这次调用是一次性的,不会在会话列表里再留下一条「起名会话」。没装 CLI?就退回离线启发式。

**装上就用,不会偷偷改你的历史。** 第一次跑的时候 rename 会记一个"基线时间戳",
之后后台只会改"基线之后才活跃"的会话——你装 rename 之前的旧聊天不会被自动碰,
Expand Down Expand Up @@ -221,7 +221,9 @@ Antigravity 有两个形态——**IDE 版**(基于 VS Code 的客户端,带 Gem
| `openai` | 直连 OpenAI API,用**你自己的 key** | `api_key` 或 `OPENAI_API_KEY` |

开箱即用、零配置、不用粘贴任何 key,你就能得到 LLM 质量的标题(花的是你已有的
额度)。想要零成本/完全离线?设 `namer = "heuristic"`。
额度)。`auto` **优先用已登录的 `claude`(Haiku)**,没有 `claude` 才用 `codex`
(默认模型 id:**`gpt-5.3-codex-spark`**)。CLI 起名是一次性调用,不会在 Claude Code /
Codex 会话列表里再留一条「起名会话」。想要零成本/完全离线?设 `namer = "heuristic"`。
想固定使用 Codex?设 `namer = "codex"`;模型可在 `[codex] model = "..."` 里改。

**用自己的 API key。** 想用自己的 Anthropic / OpenAI 账号而不是已登录的 CLI?把
Expand Down
4 changes: 3 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ privacy are a first-class concern.
- **Titling uses your own logged-in CLI by default.** The default `auto` namer
asks the `claude` / `codex` tool you're already signed into to write the title,
so a short transcript excerpt is sent through that provider — there is no API
key to paste. The `anthropic` / `openai` namers do the same via a key you set.
key to paste. Those calls are ephemeral and must not create extra sessions in
your Claude Code / Codex history. The `anthropic` / `openai` namers do the same
via a key you set.
- **Fully offline option.** Set `namer = "heuristic"` and nothing ever leaves
your machine.
- **Conservative writes.** Reads use read-only SQLite connections; writes use a
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ build-backend = "hatchling.build"
# still `rename` (see [project.scripts]); only the published name differs,
# because the bare `rename` name on PyPI is owned by an unrelated project.
name = "rename-cli"
version = "1.0.0"
version = "1.0.1"
description = "Keep your AI coding sessions named after what they actually became — Claude Code, Codex, Cursor and more."
readme = "README.md"
license = { text = "MIT" }
Expand Down
4 changes: 2 additions & 2 deletions src/rename/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
# neither is installed. (default)
# "heuristic" - instant, fully offline, no LLM, no token cost
# "claude" - always use the `claude` CLI (defaults to the fast Haiku model)
# "codex" - always use the `codex` CLI
# "codex" - always use the `codex` CLI (defaults to gpt-5.3-codex-spark)
# "anthropic" - Anthropic API directly, with your OWN key (set api_key in the
# [anthropic] table below, or export ANTHROPIC_API_KEY)
# "openai" - OpenAI API directly, with your OWN key (set api_key in the
Expand All @@ -69,7 +69,7 @@

# Model overrides for the CLI namers (optional). These reuse your existing
# login — no API key. Defaults are the fast/cheap models, which are plenty for
# a short title.
# a short title. CLI namers run ephemerally (no extra Claude/Codex session).
[claude]
model = "haiku"

Expand Down
20 changes: 15 additions & 5 deletions src/rename/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
1. Idle gate — skip if used within `idle_seconds` (still in use).
2. No-activity short-circuit — skip if `last_active` is unchanged since we last
evaluated it (cheap: no transcript read needed).
3. Substance — skip if too few real user messages.
4. Unchanged — skip if the content hash matches the title we last wrote.
5. Otherwise it's a *candidate*: ask the namer for a title and write it back
3. Namer-artifact — skip sessions whose title or user text is the naming
prompt itself (a CLI namer / auto-titler side-effect, not real work).
4. Substance — skip if too few real user messages.
5. Unchanged — skip if the content hash matches the title we last wrote.
6. Otherwise it's a *candidate*: ask the namer for a title and write it back
if it differs.

Assessment (fast, local) is deliberately separated from naming (slow — it shells
Expand Down Expand Up @@ -52,7 +54,7 @@ def _assess(
include_historical: bool = False,
):
"""Return (status, sig, msgs) without naming. status is one of
historical | active | no-activity | thin | unchanged | candidate.
historical | active | no-activity | namer-artifact | thin | unchanged | candidate.

``historical`` means the session existed before the daemon's first
run on this machine — we leave those alone so rename never
Expand All @@ -69,7 +71,11 @@ def _assess(
prev = self.state.get(adapter.name, s.id)
if prev and prev.get("seen_active") == s.last_active:
return ("no-activity", None, None)
if util.is_namer_artifact(s.title):
return ("namer-artifact", None, None)
msgs = adapter.read_transcript(s)
if any(m.role == "user" and util.is_namer_artifact(m.text) for m in msgs):
return ("namer-artifact", None, msgs)
Comment on lines +77 to +78

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid rejecting real sessions that quote a naming prompt

Because this checks every user message using an unrestricted substring match, a legitimate coding session is permanently classified as a CLI artifact whenever the user quotes one of these prompts—for example while debugging this repository, reviewing the namer implementation, or discussing Claude's tab-title behavior. _record_skip then records seen_active, so the session is not renamed at all for that activity. Restrict detection to the known side-effect-session shape, such as an initial prompt matching the complete generated prompt, rather than any user turn containing a marker.

Useful? React with 👍 / 👎.

substantive = [
m for m in msgs if m.role == "user" and not util.is_trivial(m.text)
]
Expand All @@ -88,7 +94,7 @@ def _name(self, adapter: Adapter, s: Session, msgs: list) -> str:

def _record_skip(self, adapter, s, status, sig, now_ts) -> None:
fields: dict = {"last_seen": now_ts}
if status in ("thin", "unchanged"):
if status in ("thin", "unchanged", "namer-artifact"):
fields["seen_active"] = s.last_active
if status == "unchanged" and sig:
fields["content_sig"] = sig
Expand Down Expand Up @@ -117,6 +123,10 @@ def _plan_one(
)
if status == "no-activity":
return RenamePlan(s, "skip", reason="no activity since last check")
if status == "namer-artifact":
return RenamePlan(
s, "skip", mark_seen=True, reason="namer/CLI side-effect session"
)
if status == "thin":
return RenamePlan(
s, "skip", mark_seen=True, reason="no substantive user messages"
Expand Down
102 changes: 87 additions & 15 deletions src/rename/namers/cli_namer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
Reuses whatever login the user already has for that tool — no API key wiring,
no extra cost beyond the tool's own usage. This is the default (via ``auto``),
which prefers ``claude`` then ``codex``.

Calls are ephemeral: they must not land in the user's Claude Code / Codex
session list. A persisted namer call shows up as a real session titled with
the naming prompt itself, and rename would then try to rename *that* session
by calling the CLI again — a loop.
"""

from __future__ import annotations
Expand All @@ -17,11 +22,76 @@

_TIMEOUT = 90 # codex with reasoning can take a while; keep generous

# The fast Codex model used for titling unless the user overrides it.
# Official Codex CLI model id (OpenAI, Feb 2026). Fast enough for a 6-word title.
_CODEX_DEFAULT_MODEL = "gpt-5.3-codex-spark"
# Claude's small/fast model — plenty for a 6-word title, and cheap.
_CLAUDE_DEFAULT_MODEL = "haiku"

# Flags that stop the namer call from showing up as a real coding session.
# Some CLI versions don't know them; we retry without them if rejected.
_CLAUDE_EPHEMERAL_FLAGS = ("--bare", "--no-session-persistence")
_CODEX_EPHEMERAL_FLAGS = ("--ephemeral", "--skip-git-repo-check")


def _scratch_cwd() -> str:
"""Isolated cwd so a leaking CLI cannot dump sessions into a user project."""
path = util.state_dir() / "namer-scratch"
path.mkdir(parents=True, exist_ok=True)
return str(path)


def _unknown_flag(stderr: str) -> bool:
low = (stderr or "").lower()
return any(
needle in low
for needle in (
"unknown option",
"unknown argument",
"unexpected argument",
"unrecognized",
"invalid option",
"unexpected option",
)
)


def _run(
argv: list[str],
*,
env: dict[str, str] | None = None,
cwd: str | None = None,
):
merged = os.environ.copy()
if env:
merged.update(env)
try:
return subprocess.run(
argv,
capture_output=True,
text=True,
timeout=_TIMEOUT,
env=merged,
cwd=cwd,
)
except (subprocess.TimeoutExpired, OSError) as exc:
util.log(f"{argv[0]} namer call failed: {exc}", level="debug")
return None


def _run_ephemeral(
required: list[str],
optional: tuple[str, ...],
trailing: list[str],
*,
env: dict[str, str] | None = None,
cwd: str | None = None,
):
"""Run with persistence-killing flags; retry without them on older CLIs."""
proc = _run(required + list(optional) + trailing, env=env, cwd=cwd)
if proc is not None and proc.returncode != 0 and _unknown_flag(proc.stderr):
proc = _run(required + trailing, env=env, cwd=cwd)
Comment on lines +91 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep persistence protection on compatibility retries

When a CLI recognizes the persistence-prevention flag but rejects the other optional flag—for example, a Claude version supporting --no-session-persistence but not --bare—this retry removes the entire tuple and runs successfully without the supported protection. Likewise, codex exec --help defines --ephemeral as “Run without persisting session files to disk,” but this path drops it alongside --skip-git-repo-check. Retry only the rejected flag, or at least retain the persistence-prevention flag, otherwise the compatibility path recreates the junk sessions this change is intended to eliminate.

Useful? React with 👍 / 👎.

return proc


class CliNamer(Namer):
def __init__(self, name: str, options: dict | None = None):
Expand All @@ -43,13 +113,15 @@ def _generate_claude(self, prompt: str) -> str | None:
model = self.options.get("model", _CLAUDE_DEFAULT_MODEL)
if model:
argv += ["--model", str(model)]
argv += ["-p", prompt]
try:
proc = subprocess.run(
argv, capture_output=True, text=True, timeout=_TIMEOUT
)
except (subprocess.TimeoutExpired, OSError) as exc:
util.log(f"claude namer call failed: {exc}", level="debug")
env = {"CLAUDE_CODE_SKIP_PROMPT_HISTORY": "1"}
proc = _run_ephemeral(
argv,
_CLAUDE_EPHEMERAL_FLAGS,
["-p", prompt],
env=env,
cwd=_scratch_cwd(),
)
if proc is None:
return None
if proc.returncode != 0:
util.log(
Expand All @@ -72,13 +144,13 @@ def _generate_codex(self, prompt: str) -> str | None:
model = self.options.get("model", _CODEX_DEFAULT_MODEL)
if model:
argv += ["-m", str(model)]
argv += ["--output-last-message", out_path, prompt]
try:
proc = subprocess.run(
argv, capture_output=True, text=True, timeout=_TIMEOUT
)
except (subprocess.TimeoutExpired, OSError) as exc:
util.log(f"codex namer call failed: {exc}", level="debug")
proc = _run_ephemeral(
argv,
_CODEX_EPHEMERAL_FLAGS,
["--output-last-message", out_path, prompt],
cwd=_scratch_cwd(),
)
if proc is None:
return None
if proc.returncode != 0:
util.log(
Expand Down
16 changes: 16 additions & 0 deletions src/rename/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,22 @@ def log(msg: str, *, level: str = "info") -> None:
)
_JSONISH = re.compile(r'^[\[{]\s*["{\[]')

# Side-effect sessions created by a CLI namer (or Claude Code's own tab-titler).
# Matching is case-insensitive and works on truncated titles in the session list.
_NAMER_ARTIFACT_MARKERS = (
"you name coding-assistant sessions",
"generate a concise tab title for this coding chat",
"concise title of 3 to 6 words capturing what the user",
)


def is_namer_artifact(text: str | None) -> bool:
"""True for a title or user message that is a session-naming prompt, not work."""
if not text:
return False
low = text.casefold()
return any(marker in low for marker in _NAMER_ARTIFACT_MARKERS)


def is_noise(text: str) -> bool:
"""True for harness/tool artifacts that are not genuine conversation."""
Expand Down
Loading
Loading