diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4925865..0ef05db 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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+) @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 249f5d9..46f4ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.en.md b/README.en.md index 88995ca..b1dd75e 100644 --- a/README.en.md +++ b/README.en.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 0949ba7..8f0a8fc 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ $ rename search postgres --content # 连消息正文一起搜,带匹配片段 **标题是怎么来的。** 默认 rename 会调用你已经登录的 `claude`(或 `codex`)命令行 ——`claude --model haiku -p "…"`——所以标题是对话的真实 LLM 总结,且无需 API key。 -没装 CLI?就退回离线启发式。 +这次调用是一次性的,不会在会话列表里再留下一条「起名会话」。没装 CLI?就退回离线启发式。 **装上就用,不会偷偷改你的历史。** 第一次跑的时候 rename 会记一个"基线时间戳", 之后后台只会改"基线之后才活跃"的会话——你装 rename 之前的旧聊天不会被自动碰, @@ -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?把 diff --git a/SECURITY.md b/SECURITY.md index e062d8d..a4ac2bd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index a888673..e050c0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/rename/config.py b/src/rename/config.py index 6ce65b8..dbda08d 100644 --- a/src/rename/config.py +++ b/src/rename/config.py @@ -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 @@ -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" diff --git a/src/rename/engine.py b/src/rename/engine.py index 7c3dfb0..b9ce2b6 100644 --- a/src/rename/engine.py +++ b/src/rename/engine.py @@ -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 @@ -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 @@ -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) substantive = [ m for m in msgs if m.role == "user" and not util.is_trivial(m.text) ] @@ -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 @@ -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" diff --git a/src/rename/namers/cli_namer.py b/src/rename/namers/cli_namer.py index 0576d45..90cf118 100644 --- a/src/rename/namers/cli_namer.py +++ b/src/rename/namers/cli_namer.py @@ -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 @@ -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) + return proc + class CliNamer(Namer): def __init__(self, name: str, options: dict | None = None): @@ -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( @@ -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( diff --git a/src/rename/util.py b/src/rename/util.py index 835fdad..29c75d1 100644 --- a/src/rename/util.py +++ b/src/rename/util.py @@ -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.""" diff --git a/tests/test_engine.py b/tests/test_engine.py index 5b690dc..06338bd 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -66,6 +66,60 @@ def test_renames_idle_changed_session(tmp_path): assert adapter.writes == [("s1", "Billing export")] +def test_skips_namer_prompt_title_without_calling_namer(tmp_path): + """CLI namer side-effect sessions must not be fed back into the namer.""" + s = _idle_session( + title="You name coding-assistant sessions. Read the conversation and reply" + ) + adapter = FakeAdapter( + [s], + {"s1": [Message("user", "You name coding-assistant sessions. Read the conversation")]}, + ) + namer = FakeNamer("Should not run") + calls = {"n": 0} + orig = namer.generate + + def wrapped(*a, **k): + calls["n"] += 1 + return orig(*a, **k) + + namer.generate = wrapped + eng = _engine(tmp_path, adapter, namer) + renamed, _ = eng.tick() + assert renamed == 0 + assert adapter.writes == [] + assert calls["n"] == 0 + + +def test_skips_claude_tab_title_artifact_by_transcript(tmp_path): + s = _idle_session(title="Untitled") + adapter = FakeAdapter( + [s], + { + "s1": [ + Message( + "user", + "Generate a concise tab title for this coding chat. Rules: - 2 to 5 words.", + ) + ] + }, + ) + namer = FakeNamer("Should not run") + calls = {"n": 0} + orig = namer.generate + + def wrapped(*a, **k): + calls["n"] += 1 + return orig(*a, **k) + + namer.generate = wrapped + eng = _engine(tmp_path, adapter, namer) + renamed, _ = eng.tick() + assert renamed == 0 + assert adapter.writes == [] + assert calls["n"] == 0 + + def test_skips_active_session(tmp_path): s = Session("fake", "s1", "Old", last_active=time.time()) # just now adapter = FakeAdapter([s], TRANSCRIPT) diff --git a/tests/test_namers.py b/tests/test_namers.py index 5c949d1..97773a6 100644 --- a/tests/test_namers.py +++ b/tests/test_namers.py @@ -45,6 +45,8 @@ def test_claude_uses_fast_model_and_clean_output(monkeypatch): def fake_run(argv, **kw): seen["argv"] = argv + seen["env"] = kw.get("env") + seen["cwd"] = kw.get("cwd") # claude -p prints just the answer text return _Proc(stdout="Add CSV export and fix pagination\n") @@ -53,6 +55,28 @@ def fake_run(argv, **kw): assert title == "Add CSV export and fix pagination" assert seen["argv"][0] == "claude" assert "--model" in seen["argv"] and "haiku" in seen["argv"] + assert "--no-session-persistence" in seen["argv"] + assert "--bare" in seen["argv"] + assert seen["env"]["CLAUDE_CODE_SKIP_PROMPT_HISTORY"] == "1" + assert seen["cwd"] and seen["cwd"].endswith("namer-scratch") + + +def test_claude_retries_without_ephemeral_flags_on_unknown_option(monkeypatch): + calls = [] + + def fake_run(argv, **kw): + calls.append(argv) + if "--no-session-persistence" in argv or "--bare" in argv: + return _Proc(returncode=1, stderr="error: unknown option '--no-session-persistence'") + return _Proc(stdout="Billing export\n") + + monkeypatch.setattr(cli_namer.subprocess, "run", fake_run) + title = cli_namer.CliNamer("claude", {}).generate(_MSGS) + assert title == "Billing export" + assert len(calls) == 2 + assert "--no-session-persistence" not in calls[1] + assert "--bare" not in calls[1] + assert "-p" in calls[1] def test_claude_respects_model_override(monkeypatch): @@ -72,6 +96,7 @@ def test_codex_uses_output_last_message_and_default_model(monkeypatch): def fake_run(argv, **kw): seen["argv"] = argv + seen["cwd"] = kw.get("cwd") # codex streams a noisy transcript to stdout (must be ignored)… out_path = argv[argv.index("--output-last-message") + 1] # …and writes ONLY the final message to the file: @@ -84,6 +109,29 @@ def fake_run(argv, **kw): assert title == "CSV Export and Pagination Fix" # NOT "tokens used: 2347" assert "--output-last-message" in seen["argv"] assert "-m" in seen["argv"] and "gpt-5.3-codex-spark" in seen["argv"] + assert "--ephemeral" in seen["argv"] + assert "--skip-git-repo-check" in seen["argv"] + assert seen["cwd"] and seen["cwd"].endswith("namer-scratch") + + +def test_codex_retries_without_ephemeral_flag_on_unknown_option(monkeypatch): + calls = [] + + def fake_run(argv, **kw): + calls.append(list(argv)) + out_path = argv[argv.index("--output-last-message") + 1] + if "--ephemeral" in argv: + return _Proc(returncode=1, stderr="error: unexpected argument '--ephemeral'") + with open(out_path, "w", encoding="utf-8") as fh: + fh.write("Ok title\n") + return _Proc() + + monkeypatch.setattr(cli_namer.subprocess, "run", fake_run) + title = cli_namer.CliNamer("codex", {}).generate(_MSGS) + assert title == "Ok title" + assert len(calls) == 2 + assert "--ephemeral" not in calls[1] + assert "--output-last-message" in calls[1] def test_codex_respects_model_override(monkeypatch): diff --git a/tests/test_util.py b/tests/test_util.py index 80c19fc..c08e6d1 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -12,6 +12,20 @@ def test_is_noise_catches_harness_artifacts(): assert not util.is_noise("修复登录页面的样式问题") +def test_is_namer_artifact(): + assert util.is_namer_artifact( + "You name coding-assistant sessions. Read the conversation and reply with a " + "concise title of 3 to 6 w..." + ) + assert util.is_namer_artifact( + "Generate a concise tab title for this coding chat. Rules: - 2 to 5 words. " + "- Title Case. - Describe t..." + ) + assert not util.is_namer_artifact("Add CSV export to the reports page") + assert not util.is_namer_artifact(None) + assert not util.is_namer_artifact("") + + def test_is_trivial(): assert util.is_trivial("ok") assert util.is_trivial("好的")