From 2f98e2b06a65cf514b840b1828054281bf9fddf7 Mon Sep 17 00:00:00 2001 From: omartelo Date: Wed, 21 Oct 2026 16:38:12 -0300 Subject: [PATCH] fix(terminal): title Claude Code and Cursor CLI cards after their conversation A new Claude Code session is spawned without --name: the flag marks the name as the user's, and Claude Code then never writes the ai-title the card is titled from (measured on 2.1.306). Left alone it names itself after its directory and a random suffix, distinct within a checkout, but writes that name nowhere lich reads, so lich publishes no second name for it until a /rename or a fork puts one on record. terminal.Start loses its unused name argument, and native/lichclient moves with it. Cursor CLI files a generated title in the meta.json beside a chat's store.db; lich reads it on the stop report, as it already did for Kiro (title_filed.go). Crush's gap is written down. Tests changed because the contract did: Start no longer takes a caller's name, a new Claude session is unnamed, and a Claude peer with nothing recorded has no roster name. --- CHANGELOG.md | 2 + README.es.md | 1 + README.ja.md | 1 + README.ko.md | 1 + README.md | 1 + README.pt-BR.md | 1 + README.ru.md | 1 + README.zh-CN.md | 1 + docs/ceilings.md | 16 +++- docs/cli.md | 8 +- docs/hooks/session-title.md | 40 +++++--- docs/providers/claude.md | 4 +- internal/cli/cli.go | 5 +- internal/cli/text_lang_test.go | 3 + internal/cli/wire_test.go | 2 +- internal/prompt/catalog.go | 1 + internal/prompt/en.go | 3 + internal/prompt/es.go | 4 + internal/prompt/ja.go | 4 + internal/prompt/ko.go | 4 + internal/prompt/pt_br.go | 4 + internal/prompt/ru.go | 4 + internal/prompt/zh_cn.go | 4 + internal/relay/relay_test.go | 4 +- internal/relay/roster.go | 6 +- internal/relay/rostername.go | 30 +++--- internal/relay/rostername_test.go | 31 +++++-- internal/spawn/close.go | 2 +- internal/spawn/spawn.go | 11 ++- internal/spawn/spawn_test.go | 10 +- internal/terminal/account_linux_test.go | 2 +- internal/terminal/command.go | 25 ++--- internal/terminal/exit_watch_test.go | 4 +- internal/terminal/mcpservers_spawn_test.go | 2 +- internal/terminal/profile.go | 2 +- internal/terminal/profile_omp_unix_test.go | 2 +- internal/terminal/profile_unix_test.go | 10 +- internal/terminal/profile_xdg_linux_test.go | 2 +- internal/terminal/respawn_test.go | 10 +- internal/terminal/sandbox_report_test.go | 2 +- internal/terminal/sandbox_spawn_linux_test.go | 4 +- internal/terminal/spawn_test.go | 2 +- internal/terminal/start.go | 23 +++-- internal/terminal/start_ultracode_test.go | 6 +- internal/terminal/startgate_test.go | 2 +- internal/terminal/subagent_cards_unix_test.go | 2 +- internal/terminal/terminal.go | 10 +- internal/terminal/terminal_test.go | 60 ++++++------ internal/terminal/title_filed.go | 93 +++++++++++++++++++ ...title_kiro_test.go => title_filed_test.go} | 10 +- ...ing_test.go => title_filed_wiring_test.go} | 86 ++++++++++++++++- internal/terminal/title_kiro.go | 70 -------------- internal/terminal/usage_cost_test.go | 8 +- native/lichclient/api.go | 4 +- 54 files changed, 429 insertions(+), 221 deletions(-) create mode 100644 internal/terminal/title_filed.go rename internal/terminal/{title_kiro_test.go => title_filed_test.go} (82%) rename internal/terminal/{title_kiro_wiring_test.go => title_filed_wiring_test.go} (55%) delete mode 100644 internal/terminal/title_kiro.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b9aef643..f33327400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Pulls screen shows a changed image in a pull request's Files tab, before and after side by side, as the Review tab does, where it used to say only "Binary file". An edited Markdown file there has the preview button too, where only an added one had it. lich reads the file from your clone when it holds the commit and from GitHub otherwise. - `lich mcp` answers MCP clients on protocol revision 2026-07-28, which name the version on each request instead of opening with `initialize`, and serves their `server/discover`. Clients that open with `initialize` are answered exactly as before. +- A Cursor CLI card takes the name Cursor gives the conversation, once the first turn ends, where it kept the name lich gave it. ### Changed @@ -21,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Resolve conflicts, Fix CI errors and a worktree started from an issue send their message to a new Claude Code session as soon as it is at its prompt, where it could sit unsent for up to a minute, until you pressed Enter. lich read the terminal's own answers to Claude Code's startup questions as you typing at that prompt, and held the message back for you. - A long Markdown file, such as lich's own CHANGELOG.md, previews in the Code and Review tabs without freezing the window: the preview draws only the part on screen, where it used to lay out the whole file on every frame. - A Markdown preview keeps the words apart where the source wraps a line, where they used to run together, and a table no longer squeezes a narrow column into broken words: each column is at least as wide as its longest word. +- A Claude Code card takes the title Claude Code gives the conversation again, where it kept the name lich gave it. lich now starts a new Claude Code session without `--name`, which stopped Claude Code titling it. The session picks its own name in Claude Code's agent list, a different one from the card's, and `lich sessions`, `lich whoami` and `list_sessions` show no second name for it until a `/rename` gives it one. ## [0.76.0] - 2026-10-21 diff --git a/README.es.md b/README.es.md index bf4499a72..8a39cd786 100644 --- a/README.es.md +++ b/README.es.md @@ -114,6 +114,7 @@ porque lee lo que cada CLI deja escrito, y no hay dos que registren lo mismo. | Cuánto queda de tu plan | sí | sí | sí | solo OpenCode Go | no | no | sí | no | | Un turno que el límite de uso de tu plan detuvo se reanuda cuando el límite se restablece | sí | sí | no | sus propios reintentos | sus propios reintentos | no | no | no | | Spinner mientras corre un turno, anillo cuando termina | sí | sí | sí | sí | sí | no | sí | sí | +| La tarjeta toma el nombre de la conversación | sí | por tu primer prompt | por tu primer prompt | sí | sí, desde el segundo turno | no | sí, cuando termina el primer turno | por tu primer prompt, cuando termina el primer turno | | Un pull request que abre el agente aparece en su tarjeta al terminar el turno | sí | sí | sí | sí | sí | no, cuando la ventana recupera el foco | sí | sí | | Se repinta con el aspecto nuevo al cambiar lich entre un tema claro y uno oscuro | sí | no | no | sí | sí | no | sí | no | | Campana cuando el agente está bloqueado esperándote | sí | sí | no | sí | no | no | no | no | diff --git a/README.ja.md b/README.ja.md index 75114d438..2f9ed6319 100644 --- a/README.ja.md +++ b/README.ja.md @@ -103,6 +103,7 @@ lich は各 CLI が書き出す情報を読んでいて、何を書き出すか | プランの残量を表示 | あり | あり | あり | OpenCode Go のみ | なし | なし | あり | なし | | プランの使用上限で止まったターンが、上限のリセット後に再開する | あり | あり | なし | 独自のリトライ | 独自のリトライ | なし | なし | なし | | ターン実行中はスピナー、終了時はリングを表示 | あり | あり | あり | あり | あり | なし | あり | あり | +| カードに会話の名前が付く | あり | 最初のプロンプトから | 最初のプロンプトから | あり | あり(2 ターン目から) | なし | あり(最初のターン終了時) | 最初のプロンプトから(最初のターン終了時) | | エージェントが作成したプルリクエストが、ターン終了時にカードに表示される | あり | あり | あり | あり | あり | なし(ウィンドウがフォーカスを取り戻した時点で表示) | あり | あり | | lich をライトテーマとダークテーマで切り替えると、新しい配色で再描画される | あり | なし | なし | あり | あり | なし | あり | なし | | エージェントが入力待ちで止まるとベルが鳴る | あり | あり | なし | あり | なし | なし | なし | なし | diff --git a/README.ko.md b/README.ko.md index baaa7f5d3..488614bcd 100644 --- a/README.ko.md +++ b/README.ko.md @@ -103,6 +103,7 @@ lich로 할 수 있는 일: | 남은 플랜 사용량 | 예 | 예 | 예 | OpenCode Go만 | 아니요 | 아니요 | 예 | 아니요 | | 플랜 사용 한도에 걸려 멈춘 턴이 한도가 초기화되면 다시 이어짐 | 예 | 예 | 아니요 | CLI가 직접 재시도 | CLI가 직접 재시도 | 아니요 | 아니요 | 아니요 | | 턴이 실행되는 동안 스피너, 끝나면 벨 | 예 | 예 | 예 | 예 | 예 | 아니요 | 예 | 예 | +| 카드 이름이 대화에 맞춰 붙음 | 예 | 첫 프롬프트로 | 첫 프롬프트로 | 예 | 예, 두 번째 턴부터 | 아니요 | 예, 첫 턴이 끝날 때 | 첫 프롬프트로, 첫 턴이 끝날 때 | | 에이전트가 연 풀 리퀘스트가 턴이 끝나면 카드에 표시됨 | 예 | 예 | 예 | 예 | 예 | 아니요, 창이 다시 포커스를 받을 때 표시됨 | 예 | 예 | | lich를 라이트/다크 테마로 전환하면 바뀐 모양으로 다시 그려짐 | 예 | 아니요 | 아니요 | 예 | 예 | 아니요 | 예 | 아니요 | | 에이전트가 내 응답을 기다리며 막혔을 때 벨 | 예 | 예 | 아니요 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | diff --git a/README.md b/README.md index d97a99c32..7a7935ebb 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ reads what each CLI writes down and no two of them write down the same things. | How much of your plan is left | yes | yes | yes | OpenCode Go only | no | no | yes | no | | A turn your plan's usage limit stopped picks up again once the limit resets | yes | yes | no | its own retries | its own retries | no | no | no | | Spinner while a turn runs, ring when it ends | yes | yes | yes | yes | yes | no | yes | yes | +| The card names itself after the conversation | yes | after your first prompt | after your first prompt | yes | yes, from the second turn | no | yes, once the first turn ends | after your first prompt, once the first turn ends | | A pull request the agent opens shows on its card when the turn ends | yes | yes | yes | yes | yes | no, once the window regains focus | yes | yes | | Repaints in the new look when you switch lich between a light and a dark theme | yes | no | no | yes | yes | no | yes | no | | Bell when the agent is blocked on you | yes | yes | no | yes | no | no | no | no | diff --git a/README.pt-BR.md b/README.pt-BR.md index 8f49d8920..e38c0cfaf 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -115,6 +115,7 @@ mesmas coisas. | Quanto ainda resta do seu plano | sim | sim | sim | só OpenCode Go | não | não | sim | não | | Um turno interrompido pelo limite de uso do plano continua quando o limite renova | sim | sim | não | retenta sozinho | retenta sozinho | não | não | não | | Spinner enquanto o turno roda, anel quando termina | sim | sim | sim | sim | sim | não | sim | sim | +| O card ganha o nome da conversa | sim | pelo seu primeiro prompt | pelo seu primeiro prompt | sim | sim, a partir do segundo turno | não | sim, quando o primeiro turno termina | pelo seu primeiro prompt, quando o primeiro turno termina | | Um pull request aberto pelo agente aparece no card quando o turno termina | sim | sim | sim | sim | sim | não, só quando a janela volta ao foco | sim | sim | | Se redesenha com o novo visual quando você troca o lich entre tema claro e escuro | sim | não | não | sim | sim | não | sim | não | | Sino quando o agente depende de você para continuar | sim | sim | não | sim | não | não | não | não | diff --git a/README.ru.md b/README.ru.md index 9af4a18f5..5269a6fc8 100644 --- a/README.ru.md +++ b/README.ru.md @@ -114,6 +114,7 @@ | Сколько осталось от лимитов подписки | да | да | да | только OpenCode Go | нет | нет | да | нет | | Ход, который остановился на лимите подписки, продолжается сам, когда лимит сбросится | да | да | нет | свои повторные попытки | свои повторные попытки | нет | нет | нет | | Индикатор, пока идёт ход, и кольцо, когда он закончился | да | да | да | да | да | нет | да | да | +| Карточка получает название разговора | да | по первому запросу | по первому запросу | да | да, со второго хода | нет | да, когда закончится первый ход | по первому запросу, когда закончится первый ход | | Pull request, открытый агентом, появляется на его карточке по окончании хода | да | да | да | да | да | нет, только когда окно снова получит фокус | да | да | | Перерисовывается в новом виде, когда вы переключаете lich между светлой и тёмной темой | да | нет | нет | да | да | нет | да | нет | | Колокольчик, когда агент не может продолжить без вас | да | да | нет | да | нет | нет | нет | нет | diff --git a/README.zh-CN.md b/README.zh-CN.md index e96ba0568..e3516a816 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -91,6 +91,7 @@ | 套餐剩余额度 | 有 | 有 | 有 | 仅 OpenCode Go | 无 | 无 | 有 | 无 | | 因套餐用量上限中断的一轮,在额度重置后自动继续 | 有 | 有 | 无 | 由其自身重试 | 由其自身重试 | 无 | 无 | 无 | | 一轮运行中显示转圈,结束时显示圆环 | 有 | 有 | 有 | 有 | 有 | 无 | 有 | 有 | +| 卡片以对话命名 | 有 | 取自你的第一条提示 | 取自你的第一条提示 | 有 | 有,从第二轮起 | 无 | 有,第一轮结束时 | 取自你的第一条提示,第一轮结束时 | | 智能体开的 PR 在一轮结束时显示在卡片上 | 有 | 有 | 有 | 有 | 有 | 无,等窗口重新获得焦点时显示 | 有 | 有 | | lich 在浅色和深色主题间切换时,按新主题重绘 | 有 | 无 | 无 | 有 | 有 | 无 | 有 | 无 | | 智能体等你处理时响铃 | 有 | 有 | 无 | 有 | 无 | 无 | 无 | 无 | diff --git a/docs/ceilings.md b/docs/ceilings.md index e38f8f83e..17c22eb80 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -91,8 +91,11 @@ work when nobody knows it and that the call site never shows. The mechanism and on the first tool call, so a turn that calls no tool has nothing to read, and the asker hears nothing until the ticket's deadline. A pull request a Crush session opens reaches its card only when the window regains focus (`native/cmd/lich-native/gitstate.go` `recheckMissingPR`, `cardstate.go` `setFocused`): `gh pr create` moves - neither branch nor HEAD, so a card showing no pull request asks gh again when its session's turn ends. The day - Crush ships `Stop`, the existing `report-state.sh done` closes both. + neither branch nor HEAD, so a card showing no pull request asks gh again when its session's turn ends. A Crush + card is never named after its conversation either: Crush files a title in that same database, and nothing tells + lich when to read it (`docs/hooks/session-title.md`). The day Crush ships `Stop`, the existing + `report-state.sh done` closes the first two, and a read like Kiro's and Cursor's (`internal/terminal`, + `title_filed.go`) the third. - **A CLI run as a tool inside a session is told apart by conversation id, and Kiro has none to tell by** (`internal/terminal/nested.go`, docs/hooks/session-state.md "Nested agent CLIs"): a `claude -p` the agent runs inherits the card's `LICH_*` env and reports as it. A state report naming another conversation is dropped, and a @@ -242,6 +245,15 @@ work when nobody knows it and that the call site never shows. The mechanism and ## Relay, errands and subagents +- **A new Claude Code session's roster name is its own, and lich does not know it** (`internal/terminal`, + `nameArgs`; `internal/relay`, `RosterNameOf`): lich spawns it without `--name`, because a name passed there is + the user's to Claude Code and a session holding one never writes the `ai-title` its card is titled from + (measured on 2.1.306). It names itself after its directory and a random suffix, which keeps the roster + unambiguous, but writes that name only into `~/.claude/sessions/.json`, keyed by a pid a confined + session's namespace hides. So `list_sessions`, `lich sessions` and `lich whoami` show no second name for it, + and a name an agent read off Claude Code's own `/list-agents` is not one lich resolves: that agent reaches the + session through its own messaging or by the card's label. A `/rename` or a fork puts a name on record, and from + then on lich answers to it; a fork pays for it with its `ai-title`. - **A prompt in use is recognised from the bytes going in, never from the line itself** (`internal/terminal/draft.go`; typed deliveries only, a Claude Code session with its mod is handed the message, see below): a relayed message pastes at the prompt and sends an Enter behind it, so lich holds the delivery back while the user has unsent input there. What it counts is printable input since the last diff --git a/docs/cli.md b/docs/cli.md index 8ddf2924f..5575181ee 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -205,8 +205,9 @@ prompt is left alone and the task does not wait for them to finish it. A task th collected is typed as usual. - `` is the label on the card, **or** the roster name that session - answers to (`myrepo-a1b2`, the one lich passes as `--name` and a mention - writes at a prompt). Both name the same session and both are accepted, because + answers to (`myrepo-a1b2`, lich's own for most providers; for Claude Code the + one a `/rename` or a fork put on record, which a mention writes at a prompt, + and none at all for a new session that named itself). Both name the same session and both are accepted, because an agent holding one of them should never have to know which door it is standing at — offering one name and accepting the other is what once made an agent treat a single session as two and use both channels at once. The label @@ -1366,7 +1367,8 @@ Three things keep that from repeating, and none of them asks the user to choose: 1. **One address space.** The relay answers to the card label *and* to the peer roster name — the one the session's own record carries, read back per lookup (`internal/terminal.AgentName`) and falling back to lich's derivation when - there is nothing to read (`internal/relay/rostername.go`). A mention that + there is nothing to read, except for Claude Code, whose new sessions name + themselves where lich cannot read it (`internal/relay/rostername.go`). A mention that slips into lich's tool works instead of erroring, and goes on working after a `/rename`. 2. **Both names travel.** `list_sessions` and every miss report the label and diff --git a/docs/hooks/session-title.md b/docs/hooks/session-title.md index 1aa3d7fef..84986b208 100644 --- a/docs/hooks/session-title.md +++ b/docs/hooks/session-title.md @@ -30,7 +30,7 @@ Both sides test against the payloads in | Claude Code hook | Codex hook | Antigravity hook | opencode event | oh-my-pi event | Crush hook | Cursor CLI hook | Kiro CLI hook | action | |---------------------------|---------------------------|---------------------------|-------------------|-------------------------------|------------|-----------------|---------------|--------------------------------------------------| -| `PostToolUse` + `Stop` | `PostToolUse` + `Stop` | `PreInvocation` + `Stop` | `session.updated` | `session_stop` + `turn_start` | — | — | `stop`† | set the session label to `title` (if still auto) | +| `PostToolUse` + `Stop` | `PostToolUse` + `Stop` | `PreInvocation` + `Stop` | `session.updated` | `session_stop` + `turn_start` | — | `Stop`‡ | `stop`† | set the session label to `title` (if still auto) | † **Kiro registers no title report, and lich reads its title instead.** The report is a *script that reads a transcript path off the hook's own stdin*, and @@ -41,11 +41,18 @@ metadata, so lich resolves that file itself: on the `stop` report a Kiro session does send (the `done` row of [session-state.md](session-state.md)), it reads `title` out of `~/.kiro/sessions/cli/.json`, the same file the context readout is taken from, and applies it through the guarded write below -(`internal/terminal/title_kiro.go`). A missing file, one caught mid-write and a +(`internal/terminal/title_filed.go`). A missing file, one caught mid-write and a metadata carrying no title are all silence: the card keeps the name lich gave it. No hook client is involved, which is why the column is marked rather than filled: nothing is POSTed to this endpoint from a Kiro session. +‡ **Cursor CLI is read the same way.** Its hooks carry a transcript path, but +the transcript holds no title. The TUI files a generated one, two or three +words, as `title` in the `meta.json` beside the chat's `store.db` +(`chats///`), seconds into the first turn +(measured on 2026.10.16), and lich reads it on the `stop` report the same way. +A `cursor-agent -p` run never writes one, which leaves it nothing to read. + The `ai-title` is an internal Haiku summary of the first prompt. It does not exist at `SessionStart`, but it lands long before the turn it belongs to ends: Claude Code fires the title call **in parallel with the turn's own first model @@ -142,8 +149,9 @@ being debugged. `UPDATE sessions SET label = ? WHERE id = ? AND label_auto = 1`. A user `RenameSession` clears `label_auto`, so a manual name is never stomped. Returns whether the label actually changed. -- **Kiro's own read**: `internal/terminal/title_kiro.go`, `Service.kiroTitle`, - the one title lich reads rather than receives (see the footnote above), taken +- **Kiro's and Cursor's own read**: `internal/terminal/title_filed.go`, + `Service.filedTitle`, the titles lich reads rather than receives (see the + footnotes above), taken off the `done` report and handed to the same guarded write. - **Live update** — `internal/terminal/terminal.go`: when the label changed, emits the global app event `session-title` (`{id, label}`); @@ -168,13 +176,23 @@ being debugged. was `1+1 is?` keeps the name lich gave it, correctly, since the prompt would fit on the card anyway. Absence there is the CLI's behaviour, not a broken hook, and it is the first thing to rule out before debugging one. -- **A Kiro card is named at the turn's end, never during it.** Every other - harness reports in-turn, which is what stops a ten-minute first turn showing - `Session 3` for all ten minutes; Kiro's title is read off the `stop` report, - because reading it on each `busy` would cost a file read per tool call to - learn the same name. So a Kiro card wears the name lich gave it for the whole - of its first turn, where the other harnesses rename it seconds in, and a first - turn that never reaches a `stop` never renames it at all. +- **A Kiro or Cursor card is named at the turn's end, never during it.** Every + other harness reports in-turn, which is what stops a ten-minute first turn + showing `Session 3` for all ten minutes; their titles are read off the `stop` + report, because reading them on each `busy` would cost a file read per tool + call to learn the same name. So these cards wear the name lich gave them for + the whole of their first turn, where the other harnesses rename theirs seconds + in, and a first turn that never reaches a `stop` never renames one at all. +- **A Crush card is never titled.** Crush does generate one, into the `title` + column of `.crush/crush.db`, but it raises no hook at a turn's end, and the + conversation id reaches lich only on the first tool call, before the title is + sure to be there. A read at that call would title some cards and miss others + with nothing to say which; the day Crush ships `Stop`, the read Kiro and + Cursor get is the one to add. +- **A Claude Code session spawned with `--name` is never titled.** The flag + marks the name as the user's, and Claude Code then writes no `ai-title` at all + (measured on 2.1.306). lich passes it only to a fork, which is why a forked + card keeps the name lich gave it, and spawns every new session without one. - **Reacts to the first prompt, not later pivots reliably.** Claude Code may or may not refresh the `ai-title` mid-session; lich applies whatever the hook last sent while the label is still auto. diff --git a/docs/providers/claude.md b/docs/providers/claude.md index a418bd8fb..5714b6dce 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -48,7 +48,9 @@ The full readout: per-turn token counts, and the window its mod reports, else on A conversation lich spawned is told from a plain `claude` run by the name lich spawns it under (`relay.IsRosterName` on the first `custom-title`), which covers the conversations whose rows were deleted before the tombstones of schema version 8: measured on a real workspace, it marked 490 of the 539 Claude conversations -lich held no row for, and the 49 left had no title at all, which is a plain `claude` run. +lich held no row for, and the 49 left had no title at all, which is a plain `claude` run. A new session is no +longer spawned under a name (`nameArgs`), so the name only tells apart the conversations born before that; the +tombstones cover every row deleted since. ## Plugin install diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 202d65a5e..69235f92c 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -318,7 +318,7 @@ func (c *client) sessions(args []string) error { // agent treat a single session as two. fmt.Fprintln(c.stdout, "session\tproject\tprovider\tname\tstate") for _, p := range peers { - fmt.Fprintf(c.stdout, "%s\t%s\t%s\t%s\t%s\n", p.Label, p.Project, p.Kind, p.Name, sessionState(p.State)) + fmt.Fprintf(c.stdout, "%s\t%s\t%s\t%s\t%s\n", p.Label, p.Project, p.Kind, orDash(p.Name), sessionState(p.State)) } return nil } @@ -781,6 +781,9 @@ func openedText(lang prompt.Lang, opened spawn.Session) string { if opened.Confined { confined = text.OpenedConfined } + if opened.Name == "" { + return fmt.Sprintf(text.OpenedSessionLabelOnly, opened.Label, opened.Kind, where, confined) + } return fmt.Sprintf(text.OpenedSession, opened.Label, opened.Kind, where, confined, opened.Name) } diff --git a/internal/cli/text_lang_test.go b/internal/cli/text_lang_test.go index 49ed74ce1..5e28579e3 100644 --- a/internal/cli/text_lang_test.go +++ b/internal/cli/text_lang_test.go @@ -71,6 +71,9 @@ func TestEveryTextRendersInEveryLanguage(t *testing.T) { t.Errorf("%s: the opened text lost %q", lang, literal) } } + if strings.Contains(texts["opened plain"], `""`) { + t.Errorf("%s: a session with no second name was given an empty one:\n%s", lang, texts["opened plain"]) + } if !strings.Contains(texts["handover"], "send_to_session") || !strings.Contains(texts["handover"], "private") { t.Errorf("%s: the private hand over lost the tool call:\n%s", lang, texts["handover"]) } diff --git a/internal/cli/wire_test.go b/internal/cli/wire_test.go index 783ccd627..dfdbb8fed 100644 --- a/internal/cli/wire_test.go +++ b/internal/cli/wire_test.go @@ -429,7 +429,7 @@ func (s *spawnTerminal) RunModCommand( return terminal.ModOutcome{ID: "m1", State: terminal.ModAcked, OK: true}, nil } -func (s *spawnTerminal) Start(_, _, cwd, kind, _, _ string, _ bool, _, _ int) error { +func (s *spawnTerminal) Start(_, _, cwd, kind, _ string, _ bool, _, _ int) error { s.mu.Lock() defer s.mu.Unlock() s.cwd, s.kind = cwd, kind diff --git a/internal/prompt/catalog.go b/internal/prompt/catalog.go index 70febb965..f4404b572 100644 --- a/internal/prompt/catalog.go +++ b/internal/prompt/catalog.go @@ -77,6 +77,7 @@ type Catalog struct { OpenedWorktree string OpenedConfined string OpenedSession string + OpenedSessionLabelOnly string ClosedRemoved string ClosedKept string ClosedPlain string diff --git a/internal/prompt/en.go b/internal/prompt/en.go index 6e33299a1..3c5b5d732 100644 --- a/internal/prompt/en.go +++ b/internal/prompt/en.go @@ -124,6 +124,9 @@ When a [lich] message carrying a ticket arrives at YOUR prompt, you are the work OpenedSession: "Opened session %[1]q (%[2]s) in %[3]s.%[4]s\n" + "It answers to %[1]q and to %[5]q. Its agent may still be " + "starting, so a task you send it is held until the agent is up rather than lost.\n", + OpenedSessionLabelOnly: "Opened session %[1]q (%[2]s) in %[3]s.%[4]s\n" + + "It answers to %[1]q. Its agent may still be " + + "starting, so a task you send it is held until the agent is up rather than lost.\n", ClosedRemoved: "Closed %[1]q and removed its worktree %[2]s.\n", ClosedKept: "Closed %[1]q. Its worktree %[2]s is still there, and the session is parked: opening a " + "session on that branch again picks its conversation back up.\n", diff --git a/internal/prompt/es.go b/internal/prompt/es.go index 0058f53f8..872a75b35 100644 --- a/internal/prompt/es.go +++ b/internal/prompt/es.go @@ -124,6 +124,10 @@ Cuando un mensaje [lich] con ticket llega a TU prompt, eres la sesión de trabaj "Responde por %[1]q y por %[5]q. Su agente puede estar aún iniciando, " + "así que una tarea que le " + "envíes queda retenida hasta que el agente esté activo en lugar de perderse.\n", + OpenedSessionLabelOnly: "Sesión %[1]q (%[2]s) abierta en %[3]s.%[4]s\n" + + "Responde por %[1]q. Su agente puede estar aún iniciando, " + + "así que una tarea que le " + + "envíes queda retenida hasta que el agente esté activo en lugar de perderse.\n", ClosedRemoved: "Se cerró %[1]q y se eliminó su worktree %[2]s.\n", ClosedKept: "Se cerró %[1]q. Su worktree %[2]s sigue ahí y la sesión queda aparcada: volver a abrir " + "una sesión en esa rama retoma su conversación.\n", diff --git a/internal/prompt/ja.go b/internal/prompt/ja.go index 49a4ea8eb..449cff02c 100644 --- a/internal/prompt/ja.go +++ b/internal/prompt/ja.go @@ -121,6 +121,10 @@ var ja = Catalog{ "%[1]q と %[5]q のどちらの名前でも呼べます。エージェントはまだ起動中かもしれません。" + "そのため、送ったタスクは" + "失われず、エージェントが起動するまで保留されます。\n", + OpenedSessionLabelOnly: "セッション %[1]q(%[2]s)を %[3]s で開きました。%[4]s\n" + + "%[1]q という名前で呼べます。エージェントはまだ起動中かもしれません。" + + "そのため、送ったタスクは" + + "失われず、エージェントが起動するまで保留されます。\n", ClosedRemoved: "%[1]q を閉じ、worktree %[2]s を削除しました。\n", ClosedKept: "%[1]q を閉じました。worktree %[2]s は残っており、セッションは保留されています。" + "そのブランチでもう一度セッションを開くと、会話の続きから再開します。\n", diff --git a/internal/prompt/ko.go b/internal/prompt/ko.go index b0c0bd0af..580de8baf 100644 --- a/internal/prompt/ko.go +++ b/internal/prompt/ko.go @@ -116,6 +116,10 @@ var ko = Catalog{ "이 세션은 %[1]q와(과) %[5]q로 부를 수 있습니다. 에이전트가 아직 시작 중일 수 있습니다. " + "그래서 보낸 작업은 사라지지 않고 " + "에이전트가 뜰 때까지 보류됩니다.\n", + OpenedSessionLabelOnly: "%[3]s에서 세션 %[1]q(%[2]s)을(를) 열었습니다.%[4]s\n" + + "이 세션은 %[1]q로 부를 수 있습니다. 에이전트가 아직 시작 중일 수 있습니다. " + + "그래서 보낸 작업은 사라지지 않고 " + + "에이전트가 뜰 때까지 보류됩니다.\n", ClosedRemoved: "%[1]q을(를) 닫고 worktree %[2]s을(를) 삭제했습니다.\n", ClosedKept: "%[1]q을(를) 닫았습니다. worktree %[2]s은(는) 그대로 있고 세션은 보관되었습니다. " + "그 브랜치에서 세션을 다시 열면 대화를 이어 갑니다.\n", diff --git a/internal/prompt/pt_br.go b/internal/prompt/pt_br.go index fe2bb3d83..c9f1082bf 100644 --- a/internal/prompt/pt_br.go +++ b/internal/prompt/pt_br.go @@ -124,6 +124,10 @@ Quando uma mensagem [lich] com ticket chega ao SEU prompt, você é a sessão de "Ela atende por %[1]q e por %[5]q. O agente dela pode ainda estar iniciando, " + "então uma tarefa que você " + "enviar fica retida até o agente subir, em vez de se perder.\n", + OpenedSessionLabelOnly: "Sessão %[1]q (%[2]s) aberta em %[3]s.%[4]s\n" + + "Ela atende por %[1]q. O agente dela pode ainda estar iniciando, " + + "então uma tarefa que você " + + "enviar fica retida até o agente subir, em vez de se perder.\n", ClosedRemoved: "%[1]q fechada e a worktree %[2]s removida.\n", ClosedKept: "%[1]q fechada. A worktree %[2]s continua lá, e a sessão está estacionada: abrir " + "uma sessão nessa branch de novo retoma a conversa dela.\n", diff --git a/internal/prompt/ru.go b/internal/prompt/ru.go index 28dcdae8a..ab336f2e2 100644 --- a/internal/prompt/ru.go +++ b/internal/prompt/ru.go @@ -133,6 +133,10 @@ var ru = Catalog{ "Она откликается на %[1]q и на %[5]q. Её агент может ещё запускаться, " + "поэтому отправленная ей задача " + "придерживается, пока агент не запустится, а не теряется.\n", + OpenedSessionLabelOnly: "Открыта сессия %[1]q (%[2]s) в %[3]s.%[4]s\n" + + "Она откликается на %[1]q. Её агент может ещё запускаться, " + + "поэтому отправленная ей задача " + + "придерживается, пока агент не запустится, а не теряется.\n", ClosedRemoved: "Сессия %[1]q закрыта, её worktree %[2]s удалён.\n", ClosedKept: "Сессия %[1]q закрыта. Её worktree %[2]s остался на месте, а сессия отложена: если снова " + "открыть сессию на этой ветке, её разговор продолжится.\n", diff --git a/internal/prompt/zh_cn.go b/internal/prompt/zh_cn.go index 9559ba062..0c6794a45 100644 --- a/internal/prompt/zh_cn.go +++ b/internal/prompt/zh_cn.go @@ -115,6 +115,10 @@ var zhCN = Catalog{ "它可以用 %[1]q 和 %[5]q 称呼。它的智能体可能仍在启动," + "所以你发给它的任务会被扣留," + "等智能体就绪后再送入,不会丢失。\n", + OpenedSessionLabelOnly: "已打开会话 %[1]q(%[2]s),位于 %[3]s。%[4]s\n" + + "它可以用 %[1]q 称呼。它的智能体可能仍在启动," + + "所以你发给它的任务会被扣留," + + "等智能体就绪后再送入,不会丢失。\n", ClosedRemoved: "已关闭 %[1]q,并移除了它的 worktree %[2]s。\n", ClosedKept: "已关闭 %[1]q。它的 worktree %[2]s 仍然保留,会话已被搁置:" + "再次在该分支上打开会话,会接续它之前的对话。\n", diff --git a/internal/relay/relay_test.go b/internal/relay/relay_test.go index 0f58ad518..941c3ce3d 100644 --- a/internal/relay/relay_test.go +++ b/internal/relay/relay_test.go @@ -3136,8 +3136,10 @@ func TestSelfIsTheCallersOwnEntry(t *testing.T) { if err != nil { t.Fatalf("Self: %v", err) } + // No Name: a Claude Code session with nothing on record named itself + // (RosterNameOf). want := Peer{ - Label: "sender", Name: "lich-s1", Project: "lich", Kind: "claude", ID: "s1", + Label: "sender", Project: "lich", Kind: "claude", ID: "s1", Path: "/src/lich", ProjectPath: "/src/lich", } if got != want { diff --git a/internal/relay/roster.go b/internal/relay/roster.go index 72bfa43a8..dea9db893 100644 --- a/internal/relay/roster.go +++ b/internal/relay/roster.go @@ -73,7 +73,7 @@ func (s *Service) peerOf(p store.Project, sess store.Session) Peer { } return Peer{ Label: sess.Label, - Name: RosterNameOf(s.term.AgentName(sess.ID), cwd, sess.ID), + Name: RosterNameOf(sess.Kind, s.term.AgentName(sess.ID), cwd, sess.ID), Project: p.Name, Kind: sess.Kind, State: s.reportedState(sess.ID), @@ -196,6 +196,10 @@ func knownLabels(found []candidate) string { } names := make([]string, 0, len(found)) for _, c := range found { + if c.Peer.Name == "" { + names = append(names, strconv.Quote(c.Peer.Label)) + continue + } names = append(names, fmt.Sprintf("%s (%s)", strconv.Quote(c.Peer.Label), c.Peer.Name)) } return "Live sessions: " + strings.Join(names, ", ") + "." diff --git a/internal/relay/rostername.go b/internal/relay/rostername.go index 602946d3c..b94c8e09d 100644 --- a/internal/relay/rostername.go +++ b/internal/relay/rostername.go @@ -3,14 +3,16 @@ package relay import ( "path/filepath" "strings" + + "github.com/lichdotdev/lich/internal/providers" ) // A lich session has two names, and until this file they were two address // spaces. The card carries a label the user chose ("docs"); Claude Code's peer -// roster carries the name lich passes at spawn ("skipo-3939"). Both name the -// same session, and nothing in an agent's world said so — so an agent handed -// one of them reached for whichever tool it had and, on the first real run, -// used both channels at once. +// roster carries the name lich passes at a fork ("skipo-3939") or the one a +// `/rename` gave it. Both name the same session, and nothing in an agent's world +// said so — so an agent handed one of them reached for whichever tool it had +// and, on the first real run, used both channels at once. // // The relay therefore answers to either. Deriving the roster name is how lich // mints one, and RosterNameOf is how it reads one back: a session that renamed @@ -26,13 +28,20 @@ const rosterIDChars = 4 // recorded is what its agent has on record (terminal.AgentName) and wins // outright: a `/rename` typed inside a session changes the name the harness // answers to, and no derivation can see that. Falling back to the derived one -// covers everything with nothing recorded — a provider that is never handed a -// roster name, a conversation lich cannot read, a session whose first turn has -// not been written yet. -func RosterNameOf(recorded, cwd, id string) string { +// covers every provider that is never handed a roster name. +// +// Claude Code with nothing recorded answers to no name lich can know: it is born +// without --name (terminal.nameArgs), names itself after its directory and a +// random suffix, and writes that name nowhere lich reads. Empty says so, where a +// derived name would be a second name for one session that its own roster +// never shows. +func RosterNameOf(kind, recorded, cwd, id string) string { if recorded = strings.TrimSpace(recorded); recorded != "" { return recorded } + if kind == providers.Claude { + return "" + } return RosterName(cwd, id) } @@ -40,9 +49,8 @@ func RosterNameOf(recorded, cwd, id string) string { // its working directory, then four characters of its id. cwd is the session's // own directory when it has one (a worktree) and its project's otherwise. // -// Exported for the one caller outside this package that mints a session rather -// than resolving one: internal/spawn, which reports the name it opened a -// session under. +// Exported for the one caller outside this package that mints a name rather +// than resolving one: internal/terminal, which hands it to a Claude Code fork. func RosterName(cwd, id string) string { dir := rosterDir(cwd) tail := make([]rune, 0, rosterIDChars) diff --git a/internal/relay/rostername_test.go b/internal/relay/rostername_test.go index 26f12d75e..bc1b881e9 100644 --- a/internal/relay/rostername_test.go +++ b/internal/relay/rostername_test.go @@ -5,24 +5,27 @@ import "testing" // TestRosterNameOf pins the choice between the two names a session can be // addressed by: what its agent has on record beats what lich can derive, every // time. The derivation is only ever the answer when there is nothing to read — -// and a name that is only whitespace is nothing to read. +// and a name that is only whitespace is nothing to read. A Claude Code session +// with nothing on record named itself, so there is no name to give. func TestRosterNameOf(t *testing.T) { t.Parallel() tests := []struct { name string + kind string recorded string want string }{ - {name: "the recorded name wins", recorded: "reviewer", want: "reviewer"}, - {name: "trimmed on the way through", recorded: " reviewer\n", want: "reviewer"}, - {name: "nothing recorded falls back", recorded: "", want: "lich-4f2a"}, - {name: "whitespace is nothing recorded", recorded: " ", want: "lich-4f2a"}, + {name: "the recorded name wins", kind: "claude", recorded: "reviewer", want: "reviewer"}, + {name: "trimmed on the way through", kind: "claude", recorded: " reviewer\n", want: "reviewer"}, + {name: "nothing recorded falls back", kind: "codex", recorded: "", want: "lich-4f2a"}, + {name: "whitespace is nothing recorded", kind: "codex", recorded: " ", want: "lich-4f2a"}, + {name: "claude with nothing recorded has no name", kind: "claude", recorded: "", want: ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := RosterNameOf(tt.recorded, "/home/me/code/lich", "4f2a1b3c") + got := RosterNameOf(tt.kind, tt.recorded, "/home/me/code/lich", "4f2a1b3c") if got != tt.want { - t.Errorf("RosterNameOf(%q, …) = %q, want %q", tt.recorded, got, tt.want) + t.Errorf("RosterNameOf(%q, %q, …) = %q, want %q", tt.kind, tt.recorded, got, tt.want) } }) } @@ -129,3 +132,17 @@ func TestIsRosterNameRecognisesEveryBirthName(t *testing.T) { } } } + +// TestKnownLabelsLeavesOutAMissingName pins the refusal's list of who is +// reachable: a session with no second name is listed by its label alone, never +// with an empty pair of brackets an agent could read as a name. +func TestKnownLabelsLeavesOutAMissingName(t *testing.T) { + t.Parallel() + got := knownLabels([]candidate{ + {Peer: Peer{Label: "docs", Name: "lich-4f2a"}}, + {Peer: Peer{Label: "review"}}, + }) + if want := `Live sessions: "docs" (lich-4f2a), "review".`; got != want { + t.Errorf("knownLabels = %q, want %q", got, want) + } +} diff --git a/internal/spawn/close.go b/internal/spawn/close.go index a9a6e9d5d..5a017e5c0 100644 --- a/internal/spawn/close.go +++ b/internal/spawn/close.go @@ -299,7 +299,7 @@ func findSession( switch { case strings.EqualFold(sess.Label, target): byLabel = append(byLabel, located{project: p, session: sess}) - case strings.EqualFold(relay.RosterNameOf(nameOf(sess.ID), cwd, sess.ID), target): + case strings.EqualFold(relay.RosterNameOf(sess.Kind, nameOf(sess.ID), cwd, sess.ID), target): byName = append(byName, located{project: p, session: sess}) } } diff --git a/internal/spawn/spawn.go b/internal/spawn/spawn.go index 8eab15564..6724ac760 100644 --- a/internal/spawn/spawn.go +++ b/internal/spawn/spawn.go @@ -117,7 +117,7 @@ type Worktrees interface { // first viewed, and the same close it asks for when a card goes away. The // terminal service implements it. type Terminal interface { - Start(id, projectID, cwd, kind, resume, name string, fork bool, cols, rows int) error + Start(id, projectID, cwd, kind, resume string, fork bool, cols, rows int) error // CloseBy closes the session on behalf of closerID, the session that asked // for it, or empty when none did. CloseBy(id, closerID string) error @@ -142,8 +142,9 @@ type Events interface { // facts — a divergence between them is a card that does not match the session // the agent was given. // -// Name is what the session answers to in Claude Code's peer roster; Label is the -// name on its card. Both address it (see internal/relay). Path is the worktree +// Name is the second name the session answers to, empty for a Claude Code one, +// which names itself (relay.RosterNameOf); Label is the name on its card. Both +// address it (see internal/relay). Path is the worktree // the session lives in, empty for a session in the project's own directory — // the store's own spelling. NextSeq is the project's label counter after this // session took its number. OriginSessionID and OriginLabel name the session that @@ -344,7 +345,7 @@ func (s *Service) openSession(req request) (Session, error) { ProjectID: target.ID, Project: target.Name, Label: label, - Name: relay.RosterName(cwd, id), + Name: relay.RosterNameOf(kind, "", cwd, id), Kind: kind, Path: stored, NextSeq: target.NextSeq + 1, @@ -375,7 +376,7 @@ func (s *Service) openSession(req request) (Session, error) { } callerErr := s.fileCaller(target.ID, callerToFile, folder) markErr := s.markSubagent(id, label, req) - if err := s.term.Start(id, target.ID, cwd, kind, "", opened.Name, false, startCols, startRows); err != nil { + if err := s.term.Start(id, target.ID, cwd, kind, "", false, startCols, startRows); err != nil { return Session{}, fmt.Errorf("session %q was created but its terminal did not start: %w", label, err) } if err := errors.Join(callerErr, overrideErr, markErr); err != nil { diff --git a/internal/spawn/spawn_test.go b/internal/spawn/spawn_test.go index cd3b87a31..a8f6b5ea2 100644 --- a/internal/spawn/spawn_test.go +++ b/internal/spawn/spawn_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/lichdotdev/lich/internal/project" + "github.com/lichdotdev/lich/internal/relay" "github.com/lichdotdev/lich/internal/store" "github.com/lichdotdev/lich/internal/terminal" ) @@ -343,7 +344,6 @@ type started struct { projectID string cwd string kind string - name string cols int rows int } @@ -383,9 +383,9 @@ func (f *fakeTerminal) CloseBy(id, closerID string) error { } func (f *fakeTerminal) Start( - id, projectID, cwd, kind, _, name string, _ bool, cols, rows int, + id, projectID, cwd, kind, _ string, _ bool, cols, rows int, ) error { - f.spawns = append(f.spawns, started{id, projectID, cwd, kind, name, cols, rows}) + f.spawns = append(f.spawns, started{id, projectID, cwd, kind, cols, rows}) return f.err } @@ -458,8 +458,8 @@ func TestOpenLandsInTheCallersProject(t *testing.T) { if spawn.cols != 0 || spawn.rows != 0 { t.Errorf("spawn sized %dx%d, want no size of its own", spawn.cols, spawn.rows) } - if spawn.name != opened.Name { - t.Errorf("spawned as %q but reported %q — the roster name has to be one name", spawn.name, opened.Name) + if want := relay.RosterName("/src/lich", opened.ID); opened.Name != want { + t.Errorf("reported the name %q, want %q, the one the relay resolves", opened.Name, want) } if len(events.events) != 1 || events.events[0].name != OpenedEventName { t.Fatalf("events = %+v, want one %s", events.events, OpenedEventName) diff --git a/internal/terminal/account_linux_test.go b/internal/terminal/account_linux_test.go index 73724fe2e..6cd4b5ac4 100644 --- a/internal/terminal/account_linux_test.go +++ b/internal/terminal/account_linux_test.go @@ -25,7 +25,7 @@ func TestSessionAccountReadsWhatTheWrapperExecsInto(t *testing.T) { } svc := New(stubBins{bin: bin}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } diff --git a/internal/terminal/command.go b/internal/terminal/command.go index 7c6f57f09..c4bfaaf04 100644 --- a/internal/terminal/command.go +++ b/internal/terminal/command.go @@ -253,31 +253,34 @@ func resolveBin(kind, bin string) string { // nameArgs returns the arguments that name a session in the provider's peer // roster, or nil when the provider keeps no roster, the session is resuming, or -// the name is unusable. Claude Code is the only one with a roster, and left to -// itself it names a session after its working directory — the same directory -// for every session lich runs in one checkout, which is exactly the set the -// user has to tell apart. A name that would be read as a flag is dropped rather -// than passed on. +// there is no usable name. Claude Code is the only one with a roster. A name +// that would be read as a flag is dropped rather than passed on. +// +// An empty name is the common case: a new session is left to name itself. +// --name marks the name as the user's (`nameSource: "user"` in Claude Code's +// own session record), and a session named that way never writes the +// `ai-title` lich titles its card from (measured on 2.1.306). Left alone it +// answers to its directory plus a random suffix, distinct from every other +// session in the checkout (also measured on 2.1.306), so the roster stays +// unambiguous without lich naming anything. // // lich names a session at birth only. Claude Code writes the name into the // transcript and restores it on --resume, including one the user changed with // /rename; passing --name again overrides that silently, so a resume would undo -// a decision the user made inside their own session. The cost is a transcript -// carrying no name at all — a spawn whose name flagValue rejected — which -// resumes into Claude Code's own fallback, the working directory, and that is -// the same string for every session in one checkout. +// a decision the user made inside their own session. // // A birth is not always a first birth: the name reaching here is the one the // card already answers to when there is one (Start, AgentName), so a restart — // a conversation of its own, with no name to restore — is born under the name -// the last one ended with rather than back under lich's derivation. +// the last one ended with rather than a new one of Claude Code's. // // A fork is a birth, which is why it names one despite carrying a resume id. // The conversation the provider is about to write is new, so there is no // /rename inside it to undo — and the copy inherits the parent's name (measured // on 2.1.261: the forked transcript carries the parent's `agent-name` line), so // staying silent here is what would leave two cards answering to one string in -// the roster the relay resolves against. +// the roster the relay resolves against. The price is the fork's own +// `ai-title`, which a named session never writes. func nameArgs(kind, name, resume string, fork bool) []string { if kind != providers.Claude || (resume != "" && !fork) { return nil diff --git a/internal/terminal/exit_watch_test.go b/internal/terminal/exit_watch_test.go index 263b58096..191233e7c 100644 --- a/internal/terminal/exit_watch_test.go +++ b/internal/terminal/exit_watch_test.go @@ -26,7 +26,7 @@ func TestAReapedSessionIsReportedToTheExitWatcher(t *testing.T) { }) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { t.Fatalf("start: %v", err) } waitFor(t, func() bool { @@ -38,7 +38,7 @@ func TestAReapedSessionIsReportedToTheExitWatcher(t *testing.T) { svc2 := New(stubBins{bin: echoBin(t)}, nil, hub) closed := false svc2.SetSessionExited(func(string) { closed = true }) - if err := svc2.Start("s2", "p1", t.TempDir(), "claude", "", "", false, 80, 24); err != nil { + if err := svc2.Start("s2", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { t.Fatalf("start: %v", err) } if err := svc2.Close("s2"); err != nil { diff --git a/internal/terminal/mcpservers_spawn_test.go b/internal/terminal/mcpservers_spawn_test.go index 27e46c741..d6dfa4989 100644 --- a/internal/terminal/mcpservers_spawn_test.go +++ b/internal/terminal/mcpservers_spawn_test.go @@ -30,7 +30,7 @@ func TestSpawnRecordsTheMCPServersItCanReach(t *testing.T) { svc := New(stubBins{bin: "sh", mcpServers: recorded}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", cwd, providers.OpenCode, "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", cwd, providers.OpenCode, "", false, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } diff --git a/internal/terminal/profile.go b/internal/terminal/profile.go index 7880ebf5f..9a4fd377f 100644 --- a/internal/terminal/profile.go +++ b/internal/terminal/profile.go @@ -277,6 +277,6 @@ func (s *Service) respawnAfter(cause error, live bool, id string, started spawn, if !live { return cause } - err := s.Start(id, started.projectID, started.cwd, kind, conversation, "", false, 0, 0) + err := s.Start(id, started.projectID, started.cwd, kind, conversation, false, 0, 0) return errors.Join(cause, err) } diff --git a/internal/terminal/profile_omp_unix_test.go b/internal/terminal/profile_omp_unix_test.go index 717e63ebf..fe5dcb1ec 100644 --- a/internal/terminal/profile_omp_unix_test.go +++ b/internal/terminal/profile_omp_unix_test.go @@ -116,7 +116,7 @@ func TestAnOMPSpawnRunsInItsProfile(t *testing.T) { []string{"OMP_AUTH_BROKER_URL=https://broker.example"}, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), providers.OMP, "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), providers.OMP, "", false, 80, 24); err != nil { t.Fatal(err) } waitFor(t, func() bool { return len(spawnLines(t, log)) == 1 }, "the spawn") diff --git a/internal/terminal/profile_unix_test.go b/internal/terminal/profile_unix_test.go index 450155fa5..bbdeb1252 100644 --- a/internal/terminal/profile_unix_test.go +++ b/internal/terminal/profile_unix_test.go @@ -71,7 +71,7 @@ func TestContinueInRespawnsTheCardInTheTargetProfile(t *testing.T) { []string{"CLAUDE_CODE_OAUTH_TOKEN=personal-token"}, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "c1", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "c1", false, 80, 24); err != nil { t.Fatal(err) } waitFor(t, func() bool { return len(spawnLines(t, log)) == 1 }, "the first spawn") @@ -107,7 +107,7 @@ func TestContinueInStartsAfreshACardWithNoTranscriptYet(t *testing.T) { svc := New(stubBins{bin: spawnLogBin(t, log), providerSession: "c1", movedProfiles: moved}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "", false, 80, 24); err != nil { t.Fatal(err) } waitFor(t, func() bool { return len(spawnLines(t, log)) == 1 }, "the first spawn") @@ -168,7 +168,7 @@ func TestASessionBornInADeletedProfileSaysSo(t *testing.T) { if _, err := svc.profileDir("s1", providers.Claude, ""); !errors.Is(err, claudeprofile.ErrProfileGone) { t.Errorf("spawn into a deleted profile: %v, want ErrProfileGone", err) } - err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "", "", false, 80, 24) + err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "", false, 80, 24) if !errors.Is(err, claudeprofile.ErrProfileGone) { t.Errorf("Start: %v, want ErrProfileGone", err) } @@ -248,7 +248,7 @@ func TestAResumeThatFollowsItsConversationSaysSoOnceUp(t *testing.T) { svc := New(stubBins{bin: spawnLogBin(t, log), movedProfiles: map[string]string{}}, nil, hub) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "c1", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "c1", false, 80, 24); err != nil { t.Fatal(err) } if !svc.Live("s1") { @@ -355,7 +355,7 @@ func TestACodexSpawnRunsInItsProfile(t *testing.T) { []string{"OPENAI_API_KEY=lich-key"}, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), providers.Codex, "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), providers.Codex, "", false, 80, 24); err != nil { t.Fatal(err) } waitFor(t, func() bool { return len(spawnLines(t, log)) == 1 }, "the spawn") diff --git a/internal/terminal/profile_xdg_linux_test.go b/internal/terminal/profile_xdg_linux_test.go index 1837106af..fb01c59ff 100644 --- a/internal/terminal/profile_xdg_linux_test.go +++ b/internal/terminal/profile_xdg_linux_test.go @@ -51,7 +51,7 @@ func TestAnXDGSpawnRunsInItsLinkHome(t *testing.T) { []string{"CURSOR_API_KEY=key"}, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), providers.Cursor, "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), providers.Cursor, "", false, 80, 24); err != nil { t.Fatal(err) } waitFor(t, func() bool { return len(spawnLines(t, log)) == 1 }, "the spawn") diff --git a/internal/terminal/respawn_test.go b/internal/terminal/respawn_test.go index bfda45a96..744093f51 100644 --- a/internal/terminal/respawn_test.go +++ b/internal/terminal/respawn_test.go @@ -32,7 +32,7 @@ const reapSettle = 250 * time.Millisecond // is all that goroutine has left to do. func respawn(t *testing.T, svc *Service, id string) *session { t.Helper() - if err := svc.Start(id, "p1", t.TempDir(), "claude", "", "", false, 80, 24); err != nil { + if err := svc.Start(id, "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { t.Fatalf("first start: %v", err) } svc.mu.Lock() @@ -45,7 +45,7 @@ func respawn(t *testing.T, svc *Service, id string) *session { if err := svc.Close(id); err != nil { t.Fatalf("close: %v", err) } - if err := svc.Start(id, "p1", t.TempDir(), "claude", "", "", false, 80, 24); err != nil { + if err := svc.Start(id, "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { t.Fatalf("second start: %v", err) } @@ -109,7 +109,7 @@ func TestExitEventCarriesTheExitCode(t *testing.T) { svc := New(stubBins{bin: exitBin(t, 3)}, nil, hub) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { t.Fatalf("start: %v", err) } @@ -139,7 +139,7 @@ func TestExitEventOmitsAnUnobservedStatus(t *testing.T) { svc := New(stubBins{bin: echoBin(t)}, nil, hub) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { t.Fatalf("start: %v", err) } // Killed behind the service's back: Close would evict the session first and @@ -195,7 +195,7 @@ func TestReapForgetsOpenTurn(t *testing.T) { var open atomic.Int64 svc.turns.onOpen = func(n int) { open.Store(int64(n)) } id := "reaped" - if err := svc.Start(id, "p1", t.TempDir(), "claude", "", "", false, 80, 24); err != nil { + if err := svc.Start(id, "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { t.Fatalf("start: %v", err) } svc.turns.report(id, statusBusy) diff --git a/internal/terminal/sandbox_report_test.go b/internal/terminal/sandbox_report_test.go index bdbf910e7..8339db736 100644 --- a/internal/terminal/sandbox_report_test.go +++ b/internal/terminal/sandbox_report_test.go @@ -66,7 +66,7 @@ func TestStartReportsNoLinksForAnUnconfinedSpawn(t *testing.T) { svc := New(stubBins{bin: stayAliveBin(t), sandboxLinks: recorded}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "", false, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } diff --git a/internal/terminal/sandbox_spawn_linux_test.go b/internal/terminal/sandbox_spawn_linux_test.go index 41812742e..673c9e643 100644 --- a/internal/terminal/sandbox_spawn_linux_test.go +++ b/internal/terminal/sandbox_spawn_linux_test.go @@ -37,7 +37,7 @@ func TestAConfinedSessionRunsInItsPTY(t *testing.T) { svc := New(stubBins{sandboxOn: true}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", cwd, KindShell, "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", cwd, KindShell, "", false, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } // Both markers are assembled from two shell variables, so the PTY's echo of @@ -105,7 +105,7 @@ func TestAConfinedSpawnRecordsWhatItsSandboxSkipped(t *testing.T) { svc := New(stubBins{sandboxOn: true, sandboxLinks: recorded}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", cwd, KindShell, "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", cwd, KindShell, "", false, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } diff --git a/internal/terminal/spawn_test.go b/internal/terminal/spawn_test.go index b21c37313..99a304545 100644 --- a/internal/terminal/spawn_test.go +++ b/internal/terminal/spawn_test.go @@ -20,7 +20,7 @@ func TestStartReportsAPtyThatCannotSpawn(t *testing.T) { t.Parallel() svc := New(stubBins{bin: "/nonexistent/lich-test-provider"}, nil, events.New()) - err := svc.Start("s1", "p1", t.TempDir(), "claude", "", "", false, 80, 24) + err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24) if err == nil { t.Fatal("Start = nil, want the spawn failure") diff --git a/internal/terminal/start.go b/internal/terminal/start.go index 6b5a7bae4..3cfbf798a 100644 --- a/internal/terminal/start.go +++ b/internal/terminal/start.go @@ -34,14 +34,14 @@ import ( // is the provider's to assign, and reaches lich through this session's // session-start report like any other. // -// name is what the caller asks the session to answer to in its provider's peer -// roster (Claude Code's `/list-agents`), and empty asks lich to name it. Only -// Claude Code has a roster; every other kind ignores it. Whatever the caller -// passes, a name already on record for this session wins (AgentName): a restart -// opens a conversation of its own, and a `/rename` typed in the last one is a -// decision the user made about this card rather than about that conversation. +// The name the session answers to in its provider's peer roster (Claude Code's +// `/list-agents`, the only provider with one) is a name already on record for +// this session (AgentName): a restart opens a conversation of its own, and a +// `/rename` typed in the last one is a decision the user made about this card +// rather than about that conversation. A fork with nothing on record is named by +// lich (nameArgs); any other session is left to name itself. func (s *Service) Start( - id, projectID, cwd, kind, resume, name string, fork bool, cols, rows int, + id, projectID, cwd, kind, resume string, fork bool, cols, rows int, ) error { // Refused before the spawn rather than dropped in resumeArgs: a fork the // provider cannot spell would reopen the parent's own conversation, and two @@ -59,12 +59,11 @@ func (s *Service) Start( } // Resolved before the lock, because it reads a file: the conversation this // spawn replaces is still on the row until its successor reports, so a name - // recorded there is the one this card answers to. Whatever is left is the - // caller's own suggestion, and lich's derivation when it had none. - if recorded := s.AgentName(id); recorded != "" { - name = recorded + // recorded there is the one this card answers to. + name := s.AgentName(id) + if name == "" && fork { + name = relay.RosterName(cwd, id) } - name = relay.RosterNameOf(name, cwd, id) // Before the lock: it may read the disk for the conversation's profile. bornIn, err := s.store.SessionProfile(id) if err != nil { diff --git a/internal/terminal/start_ultracode_test.go b/internal/terminal/start_ultracode_test.go index 442c36afb..7af0805e6 100644 --- a/internal/terminal/start_ultracode_test.go +++ b/internal/terminal/start_ultracode_test.go @@ -33,7 +33,7 @@ func TestStartPassesUltracodeToTheProcess(t *testing.T) { svc := New(tt.store, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", tt.resume, "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), "claude", tt.resume, false, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -61,7 +61,7 @@ func TestStartCarriesUltracodeOntoAFork(t *testing.T) { svc := New(store, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s2") }) - if err := svc.Start("s2", "p1", t.TempDir(), "claude", "conv-1", "", true, 80, 24); err != nil { + if err := svc.Start("s2", "p1", t.TempDir(), "claude", "conv-1", true, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -82,7 +82,7 @@ func TestStartLeavesUltracodeOffByDefault(t *testing.T) { svc := New(stubBins{bin: bin}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } diff --git a/internal/terminal/startgate_test.go b/internal/terminal/startgate_test.go index 5d873ee5f..228dc01b5 100644 --- a/internal/terminal/startgate_test.go +++ b/internal/terminal/startgate_test.go @@ -151,7 +151,7 @@ func TestStartHoldsAProviderSessionUntilItsReport(t *testing.T) { svc.SetStartReports(func(string) bool { return true }) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "", false, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } time.Sleep(readySettle + 100*time.Millisecond) diff --git a/internal/terminal/subagent_cards_unix_test.go b/internal/terminal/subagent_cards_unix_test.go index 5c2841d4d..bfd3409ec 100644 --- a/internal/terminal/subagent_cards_unix_test.go +++ b/internal/terminal/subagent_cards_unix_test.go @@ -52,7 +52,7 @@ func TestForkOfASubagentKeepsItsDepth(t *testing.T) { svc := New(store, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s2") }) - if err := svc.Start("s2", "p1", t.TempDir(), providers.Claude, "conv-worker", "", true, 80, 24); err != nil { + if err := svc.Start("s2", "p1", t.TempDir(), providers.Claude, "conv-worker", true, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } if got := store.inheritedSubagent["s2"]; got != maxSubagentDepth { diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index 51c04813f..49090e2a0 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -536,13 +536,13 @@ func (s *Service) onHookState(req hookRequest) { // the read has to have happened before the turn ends. go s.emitReadouts(req.SessionID) } - // A Kiro session's name is read here rather than reported, because Kiro - // hands its hooks no transcript path to read one from while it does file - // a title of its own (title_kiro.go). `done` is the turn end it raises, - // and the read is off-thread for the same reason the usage one is. + // A Kiro or Cursor session's name is read here rather than reported, + // because neither hands its hooks a file the title is in while each files + // one of its own (title_filed.go). `done` is the turn end they raise, and + // the read is off-thread for the same reason the usage one is. if req.State == statusDone { time.AfterFunc(turnEndFlushLag, func() { s.emitReadouts(req.SessionID) }) - go s.applyKiroTitle(req.SessionID) + go s.applyFiledTitle(req.SessionID) // Claude Code ends a turn a usage limit stopped with StopFailure, which // the plugin reports as `done`; whether it was a limit is in the // transcript, off-thread like every other read of it. diff --git a/internal/terminal/terminal_test.go b/internal/terminal/terminal_test.go index 1339374f8..2930f4a01 100644 --- a/internal/terminal/terminal_test.go +++ b/internal/terminal/terminal_test.go @@ -486,7 +486,7 @@ func TestStartIsNoopWhenAlreadyRunning(t *testing.T) { sess := spawnSession(t) svc.sessions["s1"] = sess - if err := svc.Start("s1", "p1", "", "", "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", "", "", "", false, 80, 24); err != nil { t.Errorf("Start(running) = %v, want nil", err) } if svc.sessions["s1"] != sess { @@ -538,7 +538,7 @@ func TestStartPassesResumeToTheProcess(t *testing.T) { svc := New(stubBins{bin: bin}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "abc-123", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), "claude", "abc-123", false, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -554,14 +554,14 @@ func TestStartPassesResumeToTheProcess(t *testing.T) { } // TestStartForksIntoItsOwnConversation proves the fork reaches argv with the -// parent's id — and that the fork is *named*, which is the one place a spawn -// carrying a resume id still gets --name (nameArgs). +// parent's id — and that the fork is *named*, under lich's derivation, which is +// the one place a spawn carrying a resume id still gets --name (nameArgs). func TestStartForksIntoItsOwnConversation(t *testing.T) { bin := stayAliveBin(t) svc := New(stubBins{bin: bin}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - err := svc.Start("s1", "p1", t.TempDir(), "claude", "abc-123", "lich-9c1b", true, 80, 24) + err := svc.Start("s1", "p1", t.TempDir(), "claude", "abc-123", true, 80, 24) if err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -570,8 +570,8 @@ func TestStartForksIntoItsOwnConversation(t *testing.T) { got := spawnedArgs(t, svc, "s1") svc.mu.Unlock() - if name := spawnName(got); name != "lich-9c1b" { - t.Errorf("fork spawned under %q, want the name the caller gave it", name) + if name := spawnName(got); !strings.HasSuffix(name, "-s1") { + t.Errorf("fork spawned under %q, want lich's derivation, ending in its id", name) } want := []string{bin, "--resume", "abc-123", "--fork-session", "--mcp-config"} spawnPins(t, got, want...) @@ -586,7 +586,7 @@ func TestStartRefusesAForkTheProviderCannotSpell(t *testing.T) { svc := New(stubBins{bin: bin}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - err := svc.Start("s1", "p1", t.TempDir(), "crush", "abc-123", "", true, 80, 24) + err := svc.Start("s1", "p1", t.TempDir(), "crush", "abc-123", true, 80, 24) if err == nil { t.Fatal("Start = nil, want a refusal for a kind that cannot fork") } @@ -606,7 +606,7 @@ func TestStartRefusesAForkWithNothingToBranch(t *testing.T) { svc := New(stubBins{bin: bin}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", "", true, 80, 24); err == nil { + if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", true, 80, 24); err == nil { t.Fatal("Start = nil, want a refusal for a fork with no resume id") } if svc.Live("s1") { @@ -621,7 +621,7 @@ func TestStartWithoutResumeSpawnsBare(t *testing.T) { svc := New(stubBins{bin: bin}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -645,7 +645,7 @@ func TestStartCarriesTheBriefingIntoTheSpawn(t *testing.T) { svc := New(stubBins{bin: bin}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -929,7 +929,7 @@ func TestStartPassesSkipPermissionsToTheProcess(t *testing.T) { svc := New(stubBins{bin: bin, skipPerms: true}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -944,22 +944,21 @@ func TestStartPassesSkipPermissionsToTheProcess(t *testing.T) { spawnPins(t, got, want...) } -// TestStartNamesASessionByWhatItAnswersTo proves the three rungs Start picks a -// peer name off, and that the winner reaches the spawned binary's argv in the -// order claude parses. The recorded name is the one that matters: a card whose -// conversation was renamed from the inside is respawned under that name rather -// than back under lich's derivation, which is the whole reason the read exists. +// TestStartNamesASessionByWhatItAnswersTo proves the rungs Start picks a peer +// name off, and that the winner reaches the spawned binary's argv in the order +// claude parses. A new session is not named at all: --name stops Claude Code +// writing the ai-title its card is titled from. The recorded name is the one +// that matters: a card whose conversation was renamed from the inside is +// respawned under that name, which is the whole reason the read exists. func TestStartNamesASessionByWhatItAnswersTo(t *testing.T) { const recorded = `{"type":"agent-name","agentName":"reviewer"}` + "\n" tests := []struct { name string - asked string transcript string want string }{ - {name: "the caller's own name", asked: "lich-4f2a", want: "lich-4f2a"}, - {name: "derived when the caller named none", want: "-s1"}, - {name: "the recorded name beats both", asked: "lich-4f2a", transcript: recorded, want: "reviewer"}, + {name: "a new session names itself", want: ""}, + {name: "the recorded name", transcript: recorded, want: "reviewer"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -972,7 +971,7 @@ func TestStartNamesASessionByWhatItAnswersTo(t *testing.T) { svc := New(store, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - err := svc.Start("s1", "p1", t.TempDir(), "claude", "", tt.asked, false, 80, 24) + err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24) if err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -981,10 +980,7 @@ func TestStartNamesASessionByWhatItAnswersTo(t *testing.T) { got := spawnedArgs(t, svc, "s1") svc.mu.Unlock() - // The derived name ends in the session id, and starts with a temporary - // directory nothing here can spell — a suffix is the whole of what that - // case owns. RosterName's own suite pins the rest. - if name := spawnName(got); !strings.HasSuffix(name, tt.want) { + if name := spawnName(got); name != tt.want { t.Errorf("spawned under %q, want %q", name, tt.want) } // As above: the registration follows, pinned by its own test. @@ -1118,7 +1114,7 @@ func TestStartPassesTheStoredEffortToTheProcess(t *testing.T) { svc := New(stubBins{bin: bin, model: "opus", effort: "high"}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -1138,7 +1134,7 @@ func TestStartPassesTheStoredModelToTheProcess(t *testing.T) { svc := New(stubBins{bin: bin, model: "opus"}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -1674,10 +1670,10 @@ func TestStartWithoutASizeCopiesTheWindow(t *testing.T) { svc := New(stubBins{bin: bin}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1"); _ = svc.Close("s2") }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", "", false, 174, 51); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 174, 51); err != nil { t.Fatalf("Start = %v, want nil", err) } - if err := svc.Start("s2", "p1", t.TempDir(), "claude", "", "", false, 0, 0); err != nil { + if err := svc.Start("s2", "p1", t.TempDir(), "claude", "", false, 0, 0); err != nil { t.Fatalf("Start without a size = %v, want nil", err) } @@ -1762,7 +1758,7 @@ func TestReadyWaitsOnlyTheSettle(t *testing.T) { svc := New(stubBins{bin: bin}, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", "", false, 80, 24); err != nil { + if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { t.Fatalf("Start = %v, want nil", err) } // The provider has to take the terminal first: readiness is the quiet after it draws, never the spawn itself. @@ -1827,7 +1823,7 @@ func TestLiveCountCountsTheSessionsWithAProcess(t *testing.T) { t.Fatalf("LiveCount = %d before any spawn, want 0", got) } for _, id := range []string{"s1", "s2"} { - if err := svc.Start(id, "p1", t.TempDir(), "claude", "", "", false, 80, 24); err != nil { + if err := svc.Start(id, "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { t.Fatalf("Start(%s) = %v", id, err) } } diff --git a/internal/terminal/title_filed.go b/internal/terminal/title_filed.go new file mode 100644 index 000000000..1e6daf964 --- /dev/null +++ b/internal/terminal/title_filed.go @@ -0,0 +1,93 @@ +package terminal + +import ( + "encoding/json" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/lichdotdev/lich/internal/providers" +) + +// The providers lich reads a session title for itself, rather than being sent +// one. The report every other harness sends is a hook script reading a +// transcript path off its own stdin (docs/hooks/session-title.md), and neither +// of these hands its hooks a file the title is in. Kiro passes a path on none of +// its five events, and files a `title`, derived from the first prompt, into the +// session metadata transcript.go resolves (kiroSessionPath), the same `.json` the +// context readout is read from. Cursor CLI hands the plugin a transcript that +// holds no title, and files a generated one into the `meta.json` beside the +// chat's store.db (cursorChatStore), seconds into the first turn of an +// interactive session (measured on 2026.10.16; a `-p` run never writes one). So +// the turn's end is where lich goes and reads it. + +// cursorChatMeta is the file Cursor CLI keeps a chat's metadata in, beside its +// store.db. +const cursorChatMeta = "meta.json" + +// metadataTitle reads the title a provider filed for one conversation. ok is +// false for every absence (no file yet, a file caught mid-write, metadata +// carrying no title), because a session whose name lich cannot read keeps the +// name lich gave it, which is silence rather than a failure. +func metadataTitle(path string) (string, bool) { + data, err := os.ReadFile(path) + if err != nil { + return "", false + } + var meta struct { + Title string `json:"title"` + } + if err := json.Unmarshal(data, &meta); err != nil { + return "", false + } + // Trimmed like the reported one is (parseSessionTitle), so both doors + // apply the same string. + title := strings.TrimSpace(meta.Title) + return title, title != "" +} + +// filedTitle resolves the title the provider filed for the conversation running +// in session id. ok is false when the session runs a provider that files none +// lich reads, when no conversation is linked to it yet, and on every absence +// above. +func (s *Service) filedTitle(id string) (string, bool) { + started := s.spawnOf(id) + if started.kind != providers.Kiro && started.kind != providers.Cursor { + return "", false + } + providerSessionID, err := s.store.ProviderSession(id) + if err != nil || providerSessionID == "" { + return "", false + } + path, ok := filedTitlePath(started.kind, providerSessionID, started.cwd) + if !ok { + return "", false + } + return metadataTitle(path) +} + +// filedTitlePath is the file kind keeps one conversation's title in. +func filedTitlePath(kind, providerSessionID, cwd string) (string, bool) { + if kind == providers.Kiro { + return kiroSessionPath(providerSessionID) + } + chatStore, ok := cursorChatStore(providerSessionID, cwd) + if !ok { + return "", false + } + return filepath.Join(filepath.Dir(chatStore), cursorChatMeta), true +} + +// applyFiledTitle publishes that title down the same path a reported one takes +// (onTitle): the store's guarded write, which leaves a session the user renamed +// alone, and the event the card redraws on. +func (s *Service) applyFiledTitle(id string) { + title, ok := s.filedTitle(id) + if !ok { + return + } + if err := s.onTitle(id, title); err != nil { + slog.Warn("terminal: apply filed title", "session", id, "err", err) + } +} diff --git a/internal/terminal/title_kiro_test.go b/internal/terminal/title_filed_test.go similarity index 82% rename from internal/terminal/title_kiro_test.go rename to internal/terminal/title_filed_test.go index e3a25d8c4..a33ef968c 100644 --- a/internal/terminal/title_kiro_test.go +++ b/internal/terminal/title_filed_test.go @@ -35,12 +35,12 @@ func TestKiroMetadataTitle(t *testing.T) { if err := os.WriteFile(path, []byte(tt.body), 0o600); err != nil { t.Fatalf("write session file: %v", err) } - got, ok := kiroMetadataTitle(path) + got, ok := metadataTitle(path) if ok != (tt.want != "") { - t.Fatalf("kiroMetadataTitle = %q, %v, want ok=%v", got, ok, tt.want != "") + t.Fatalf("metadataTitle = %q, %v, want ok=%v", got, ok, tt.want != "") } if got != tt.want { - t.Errorf("kiroMetadataTitle = %q, want %q", got, tt.want) + t.Errorf("metadataTitle = %q, want %q", got, tt.want) } }) } @@ -50,7 +50,7 @@ func TestKiroMetadataTitle(t *testing.T) { // common absence, and it must not reach the card as an error. func TestKiroMetadataTitleAbsentFile(t *testing.T) { t.Parallel() - if got, ok := kiroMetadataTitle(filepath.Join(t.TempDir(), "absent.json")); ok { - t.Errorf("kiroMetadataTitle(absent) = %q, true, want a miss", got) + if got, ok := metadataTitle(filepath.Join(t.TempDir(), "absent.json")); ok { + t.Errorf("metadataTitle(absent) = %q, true, want a miss", got) } } diff --git a/internal/terminal/title_kiro_wiring_test.go b/internal/terminal/title_filed_wiring_test.go similarity index 55% rename from internal/terminal/title_kiro_wiring_test.go rename to internal/terminal/title_filed_wiring_test.go index 9ca0cc763..f96db705b 100644 --- a/internal/terminal/title_kiro_wiring_test.go +++ b/internal/terminal/title_filed_wiring_test.go @@ -1,7 +1,7 @@ // These cover the read wired into the Service, which needs the suite's Store // stub, and that stub lives in terminal_test.go, which is Unix-only for its // real PTY spawns. Nothing here spawns anything; the tag is the stub's, not -// this file's. The read's own logic is in title_kiro_test.go and runs on every +// this file's. The read's own logic is in title_filed_test.go and runs on every // OS. //go:build !windows @@ -107,13 +107,91 @@ func TestKiroTitleResolution(t *testing.T) { t.Run(tt.name, func(t *testing.T) { svc := New(stubBins{providerSession: tt.providerSession}, nil, events.New()) svc.spawns.Store("s1", spawn{kind: tt.kind}) - got, ok := svc.kiroTitle("s1") + got, ok := svc.filedTitle("s1") if ok != (tt.want != "") { - t.Fatalf("kiroTitle = %q, %v, want ok=%v", got, ok, tt.want != "") + t.Fatalf("filedTitle = %q, %v, want ok=%v", got, ok, tt.want != "") } if got != tt.want { - t.Errorf("kiroTitle = %q, want %q", got, tt.want) + t.Errorf("filedTitle = %q, want %q", got, tt.want) } }) } } + +// plantCursorChat writes one chat's store.db, and its meta.json when meta is not +// empty, where cursorChatStore looks for them, and returns the checkout the +// chat ran in. +func plantCursorChat(t *testing.T, providerSessionID, meta string) string { + t.Helper() + base, cwd := t.TempDir(), t.TempDir() + t.Setenv("CURSOR_CONFIG_DIR", base) + chat := filepath.Join(base, "chats", cursorWorkspaceKey(cwd), providerSessionID) + if err := os.MkdirAll(chat, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(chat, "store.db"), []byte("SQLite"), 0o600); err != nil { + t.Fatal(err) + } + if meta == "" { + return cwd + } + if err := os.WriteFile(filepath.Join(chat, "meta.json"), []byte(meta), 0o600); err != nil { + t.Fatal(err) + } + return cwd +} + +// TestCursorTitlePublishedOnStop: Cursor CLI's hooks carry no title, so the +// turn's end is where lich reads the one the TUI filed beside the chat. +func TestCursorTitlePublishedOnStop(t *testing.T) { + const id = "bec68d79-a208-4e40-8d2f-f8f8964da216" + cwd := plantCursorChat(t, id, `{"hasConversation":true,"title":"Pong Game"}`) + store := &titleStore{stubBins: stubBins{providerSession: id}, titles: make(chan string, 1)} + svc := New(store, nil, events.New()) + svc.spawns.Store("s1", spawn{kind: providers.Cursor, cwd: cwd}) + + // Every Cursor turn opens with a `busy`, and a `done` with none before it is + // dropped as a repeated ending (repeatedEnding). + svc.onHookState(hookRequest{SessionID: "s1", State: statusBusy}) + svc.onHookState(hookRequest{SessionID: "s1", State: statusDone}) + + select { + case got := <-store.titles: + if got != "Pong Game" { + t.Errorf("title = %q, want %q", got, "Pong Game") + } + case <-time.After(5 * time.Second): + t.Fatal("the turn ended and no title reached the store") + } +} + +// A chat Cursor has not titled yet, and one in another checkout, are both +// silence: the card keeps the name lich gave it. +func TestCursorTitleResolution(t *testing.T) { + const id = "bec68d79-a208-4e40-8d2f-f8f8964da216" + titled := plantCursorChat(t, id, `{"title":"Pong Game"}`) + tests := []struct { + name string + cwd string + want string + }{ + {"the checkout the chat ran in", titled, "Pong Game"}, + {"another checkout", t.TempDir(), ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := New(stubBins{providerSession: id}, nil, events.New()) + svc.spawns.Store("s1", spawn{kind: providers.Cursor, cwd: tt.cwd}) + got, ok := svc.filedTitle("s1") + if ok != (tt.want != "") || got != tt.want { + t.Errorf("filedTitle = %q, %v, want %q", got, ok, tt.want) + } + }) + } + untitled := plantCursorChat(t, id, `{"hasConversation":true}`) + svc := New(stubBins{providerSession: id}, nil, events.New()) + svc.spawns.Store("s1", spawn{kind: providers.Cursor, cwd: untitled}) + if got, ok := svc.filedTitle("s1"); ok { + t.Errorf("an untitled chat titled the card %q", got) + } +} diff --git a/internal/terminal/title_kiro.go b/internal/terminal/title_kiro.go deleted file mode 100644 index c729b8209..000000000 --- a/internal/terminal/title_kiro.go +++ /dev/null @@ -1,70 +0,0 @@ -package terminal - -import ( - "encoding/json" - "log/slog" - "os" - "strings" - - "github.com/lichdotdev/lich/internal/providers" -) - -// The Kiro CLI side of the session title, and the one provider lich reads a -// title for itself. The report every other harness sends is a hook script -// reading a transcript path off its own stdin, and Kiro passes one on none of -// its five events (docs/hooks/session-title.md). It does file a `title`, -// derived from the first prompt, into the session metadata transcript.go -// resolves (kiroSessionPath): the same `.json` the context readout is read -// from. So the turn's end is where lich goes and reads it. - -// kiroMetadataTitle reads the title Kiro filed for one conversation. ok is -// false for every absence (no file yet, a file caught mid-write, metadata -// carrying no title), because a session whose name lich cannot read keeps the -// name lich gave it, which is silence rather than a failure. -func kiroMetadataTitle(path string) (string, bool) { - data, err := os.ReadFile(path) - if err != nil { - return "", false - } - var meta struct { - Title string `json:"title"` - } - if err := json.Unmarshal(data, &meta); err != nil { - return "", false - } - // Trimmed like the reported one is (parseSessionTitle), so both doors - // apply the same string. - title := strings.TrimSpace(meta.Title) - return title, title != "" -} - -// kiroTitle resolves the title Kiro filed for the conversation running in -// session id. ok is false when the session runs something else, when no -// conversation is linked to it yet, and on every absence above. -func (s *Service) kiroTitle(id string) (string, bool) { - if s.kindOf(id) != providers.Kiro { - return "", false - } - providerSessionID, err := s.store.ProviderSession(id) - if err != nil || providerSessionID == "" { - return "", false - } - path, ok := kiroSessionPath(providerSessionID) - if !ok { - return "", false - } - return kiroMetadataTitle(path) -} - -// applyKiroTitle publishes that title down the same path a reported one takes -// (onTitle): the store's guarded write, which leaves a session the user renamed -// alone, and the event the card redraws on. -func (s *Service) applyKiroTitle(id string) { - title, ok := s.kiroTitle(id) - if !ok { - return - } - if err := s.onTitle(id, title); err != nil { - slog.Warn("terminal: apply kiro title", "session", id, "err", err) - } -} diff --git a/internal/terminal/usage_cost_test.go b/internal/terminal/usage_cost_test.go index 3dc1d1a4d..45d78325e 100644 --- a/internal/terminal/usage_cost_test.go +++ b/internal/terminal/usage_cost_test.go @@ -804,7 +804,7 @@ func TestAForkRecordsWhatItsParentHadCost(t *testing.T) { svc := New(store, nil, events.New()) t.Cleanup(func() { _ = svc.Close("fork") }) - err := svc.Start("fork", "p1", t.TempDir(), "claude", "abc-123", "", true, 80, 24) + err := svc.Start("fork", "p1", t.TempDir(), "claude", "abc-123", true, 80, 24) if err != nil { t.Fatalf("Start = %v, want nil", err) @@ -825,7 +825,7 @@ func TestAForkOfAProviderThatPricesItselfOffsetsNothing(t *testing.T) { svc := New(store, nil, events.New()) t.Cleanup(func() { _ = svc.Close("fork") }) - err := svc.Start("fork", "p1", t.TempDir(), "opencode", "abc-123", "", true, 80, 24) + err := svc.Start("fork", "p1", t.TempDir(), "opencode", "abc-123", true, 80, 24) if err != nil { t.Fatalf("Start = %v, want nil", err) @@ -847,7 +847,7 @@ func TestAResumeIsNotAFork(t *testing.T) { svc := New(store, nil, events.New()) t.Cleanup(func() { _ = svc.Close("s1") }) - err := svc.Start("s1", "p1", t.TempDir(), "claude", "abc-123", "", false, 80, 24) + err := svc.Start("s1", "p1", t.TempDir(), "claude", "abc-123", false, 80, 24) if err != nil { t.Fatalf("Start = %v, want nil", err) @@ -869,7 +869,7 @@ func TestAForkWhoseOffsetCannotBeSavedStillStarts(t *testing.T) { svc := New(store, nil, events.New()) t.Cleanup(func() { _ = svc.Close("fork") }) - err := svc.Start("fork", "p1", t.TempDir(), "claude", "abc-123", "", true, 80, 24) + err := svc.Start("fork", "p1", t.TempDir(), "claude", "abc-123", true, 80, 24) if err != nil { t.Fatalf("Start = %v, want the spawn to survive a failed offset", err) diff --git a/native/lichclient/api.go b/native/lichclient/api.go index 2ce82c45f..07fde2181 100644 --- a/native/lichclient/api.go +++ b/native/lichclient/api.go @@ -109,14 +109,14 @@ type ConversationReported struct { // Start spawns the session's process at cols x rows; a no-op when it already // runs. resume is a provider conversation id to reopen, "" to start fresh. func (c *Client) Start(ctx context.Context, id, projectID, cwd, kind, resume string, cols, rows int) error { - return c.Call(ctx, "terminal.Start", nil, id, projectID, cwd, kind, resume, "", false, cols, rows) + return c.Call(ctx, "terminal.Start", nil, id, projectID, cwd, kind, resume, false, cols, rows) } // Fork spawns the session's process branching the provider conversation // parent: the conversation is copied into one of its own, and the card that // runs parent goes on untouched. A no-op when the session already runs. func (c *Client) Fork(ctx context.Context, id, projectID, cwd, kind, parent string, cols, rows int) error { - return c.Call(ctx, "terminal.Start", nil, id, projectID, cwd, kind, parent, "", true, cols, rows) + return c.Call(ctx, "terminal.Start", nil, id, projectID, cwd, kind, parent, true, cols, rows) } // Resize sets the session's PTY size.