diff --git a/.changeset/claude-resume-titles.md b/.changeset/claude-resume-titles.md new file mode 100644 index 00000000..62ad4e22 --- /dev/null +++ b/.changeset/claude-resume-titles.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +fix(sessions): stop pinning the `w1-myapp` placeholder as Claude's session title. Local Claude spawns passed the tab name as `--name`, which is also the `/resume` picker entry and the terminal title, and a pinned title stops Claude generating its own, so every conversation of a case showed up in `/resume` as the same `w1-myapp` and none got a generated title. Only a name the user chose is pinned now; placeholder and auto-named tabs let Claude title the conversation again. Renaming a Claude tab also reaches `/resume`: the new name is appended to the conversation's transcript as the `custom-title` row `/rename` writes (a tab that was spawned with `--name` keeps re-appending its own title until its next respawn, so the rename wins from then on). Orchestrators that rely on a fixed peer name should give workers a descriptive `sessionName` rather than a `w-` one. diff --git a/CLAUDE.md b/CLAUDE.md index da788221..264f50f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -243,7 +243,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`), geometry pure in `computeLineagePath()`: one U-bridge shape hanging from the strip bottom, colors keyed on the SPAWNING tab and memoized (never by draw index). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:"` (the entrance animation queries it); skip edges whose endpoint is scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned) -**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `: ` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title) +**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title) **Maintainer bot (external)**: the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at `scripts/pr-bot/`. It moved OUT of this repository on 2026-09-14, to `~/codeman-cases/prbot/` (its own private git repo, systemd unit `codeman-pr-bot`, guide + agent rules in its own `README.md` and `CLAUDE.md`). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named `prbot-<n>` / `dscbot-<n>` on the local Codeman and holds clones under `~/.codeman/pr-bot/`, so those session names and that data dir are taken; it also fetches PR heads into `refs/pr-bot/*` of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift. diff --git a/docs/agent-control-plan.md b/docs/agent-control-plan.md index 164f2fe5..306c92e6 100644 --- a/docs/agent-control-plan.md +++ b/docs/agent-control-plan.md @@ -757,3 +757,13 @@ works, and its replies arrive tagged `from-name="w9-msgtest"` (a derived-name worker's replies carry no `from-name`). A quick-start without `sessionName` has an empty Codeman name, so the peer name stays derived: agents should name their workers. Tests: `test/name-flag-injection.test.ts`. + +Later narrowing: `--name` is not only the peer name but also the `/resume` picker +entry and the terminal title, and a pinned title stops Claude generating its own, so +pinning the `w1-myapp` placeholder listed every conversation of a case under the same +name in `/resume`. Only a manual name is pinned now (`Session.cliPinnedName`, +`nameSource === 'manual'`, carried to the builders as `cliName`); placeholder and auto +names leave Claude to title the conversation. A rename in Codeman appends a +`custom-title` row to the conversation's transcript (`claude-session-title.ts`), the +row `/rename` writes. Tests: `test/claude-resume-title.test.ts`, +`test/routes/session-name-routes.test.ts`. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index e05dbe11..fbe15b5e 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -199,6 +199,8 @@ Further detail (current geometry and colors, superseding the dip numbers above w **Opt-in, and the listener orders its checks for cost.** The prompt lands in the tab name, `mux-sessions.json`, every `session:updated` broadcast, the TUI, both home screens and `/api/search` (which matches on `sessionName`), while Read My Mind deliberately keeps prompts 0600 and out of search because prompts can carry secrets; so `autoNameSessions` is synced and default OFF, like `agentSkillEnabled`, `approvalsInboxEnabled` and `readMyMindEnabled`. The listener checks `nameSource` and derives the title BEFORE reading `settings.json`, so an already-named session costs nothing per prompt. +**`nameSource` also decides what claude is told to call the conversation.** `--name` is the prompt-box label, the `/resume` picker entry and the terminal title at once, and a pinned title stops Claude generating its own, so only a `manual` name is passed (`Session.cliPinnedName`); pinning the `w1-myapp` placeholder gave every conversation of a case the same `/resume` entry. A rename through `PUT /api/sessions/:id/name` is carried into `/resume` by appending the `custom-title` row `/rename` writes (`claude-session-title.ts`), for local transcripts only: a remote pane's lives on the remote host and a docker pane's inside the container. The route returns early on an unchanged name, because the Session Options field saves on blur and recomposes the same placeholder, which would otherwise flip it to `manual` and re-pin it. + Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`) is the one `parseSessionPrefix()` (app.js, #232) already renders as the title alone with the prefix in the tooltip, and that `_nextCaseSessionStartNumber()` still counts. The `name` setter is reached via `PUT /api/sessions/:id/name`. The listener lives in `session-listener-wiring.ts`. Tests: `test/session-auto-name.test.ts`, `test/session-listener-wiring.test.ts`, `test/routes/session-name-routes.test.ts`. ### Full-scrollback replay diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index 219f41a6..d8b26507 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -340,7 +340,7 @@ ESC=$(printf '\033') ### Starting a worker `POST /api/v1/quick-start` body (all optional): -`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}` +`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}` , `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is `.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory on the user's disk) if missing, do not retry it in a loop, and remember the name. diff --git a/plugins/codeman/skills/codeman/reference/messaging.md b/plugins/codeman/skills/codeman/reference/messaging.md index 24101c88..2d7d68d0 100644 --- a/plugins/codeman/skills/codeman/reference/messaging.md +++ b/plugins/codeman/skills/codeman/reference/messaging.md @@ -101,11 +101,16 @@ the case name, read it from the listing. From Codeman 1.16 a LOCAL claude spawn passes `--name <session name>` when the local CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in at `tmux-manager.ts:797`), so a worker's peer name usually IS its Codeman session name -(verified live: quick-start with `sessionName: "w9-msgtest"` listed as `w9-msgtest`, -and its messages arrive tagged `from-name="w9-msgtest"`; a derived-name worker's +(verified live: a quick-start `sessionName` is listed as that exact peer name, and +the worker's messages arrive tagged `from-name="<that name>"`; a derived-name worker's messages carry no `from-name`). Name your workers: a quick-start WITHOUT `sessionName` leaves the Codeman name empty, so there is nothing to pass and the -peer name stays derived. The flag is fail-closed (older/unknown CLI omits it, because an +peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: only a name the user chose +is pinned (`Session.cliPinnedName`), because `--name` is also the conversation's +`/resume` title and terminal title and suppresses Claude's own generated title. A +placeholder-shaped name (`w9-msgtest`, anything matching `isGeneratedSessionName`) +and an auto name are NOT passed, so such a worker's peer name is derived; use +`msgtest-worker` rather than `w9-msgtest`. The flag is fail-closed (older/unknown CLI omits it, because an unknown flag aborts startup and would kill every spawn) and allowlist-sanitized (a name of only unsafe characters is dropped), and the docker/remote builders never see it at all (`tmux-manager.ts:782-789`), which is why the `tmux` column stays the canonical join key @@ -196,8 +201,8 @@ idle: The contract an orchestrator follows for any fleet of two or more messaging workers. Every topology in the next section is this protocol plus a wiring diagram. -1. **Spawn with a name, and confirm hooks.** Use `quick-start` with `sessionName` (the - `--name` gate above). Session create installs the hooks block into the workspace +1. **Spawn with a name, and confirm hooks.** Use `quick-start` with a descriptive, + non-`w<N>-` `sessionName` (the `--name` gate above). Session create installs the hooks block into the workspace whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get `stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn `workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 1f0dceea..46eecc8d 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -692,8 +692,9 @@ The shape, each step verified live (probes, failure modes and safety detail in [§5.2](#52-readiness)). 2. `ListAgents`: find the worker's row by its `tmux codeman-<first 8 of session id>` column; the row's `name [ref]` is the address. On Codeman 1.16+ with claude - 2.1.224+ a worker's peer name is its Codeman session name, so pass `sessionName` - in quick-start to pick it; older setups list a name derived from the case folder. + 2.1.224+ a worker's peer name is its Codeman session name, so pass a DESCRIPTIVE + `sessionName` in quick-start to pick it (a `w<N>-` placeholder-shaped name is not + pinned, so it lists derived); older setups list a name derived from the case folder. No row = messaging is off for that worker (it is feature-flagged even on matching CLI versions, observed live): fall back to the HTTP recipes without complaint. 3. `SendMessage` the task; first contact must use the `name [ref]` form copied from diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 219f41a6..d8b26507 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -340,7 +340,7 @@ ESC=$(printf '\033') ### Starting a worker `POST /api/v1/quick-start` body (all optional): -`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}` +`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}` , `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is `.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory on the user's disk) if missing, do not retry it in a loop, and remember the name. diff --git a/skills/codeman/reference/messaging.md b/skills/codeman/reference/messaging.md index 24101c88..2d7d68d0 100644 --- a/skills/codeman/reference/messaging.md +++ b/skills/codeman/reference/messaging.md @@ -101,11 +101,16 @@ the case name, read it from the listing. From Codeman 1.16 a LOCAL claude spawn passes `--name <session name>` when the local CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in at `tmux-manager.ts:797`), so a worker's peer name usually IS its Codeman session name -(verified live: quick-start with `sessionName: "w9-msgtest"` listed as `w9-msgtest`, -and its messages arrive tagged `from-name="w9-msgtest"`; a derived-name worker's +(verified live: a quick-start `sessionName` is listed as that exact peer name, and +the worker's messages arrive tagged `from-name="<that name>"`; a derived-name worker's messages carry no `from-name`). Name your workers: a quick-start WITHOUT `sessionName` leaves the Codeman name empty, so there is nothing to pass and the -peer name stays derived. The flag is fail-closed (older/unknown CLI omits it, because an +peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: only a name the user chose +is pinned (`Session.cliPinnedName`), because `--name` is also the conversation's +`/resume` title and terminal title and suppresses Claude's own generated title. A +placeholder-shaped name (`w9-msgtest`, anything matching `isGeneratedSessionName`) +and an auto name are NOT passed, so such a worker's peer name is derived; use +`msgtest-worker` rather than `w9-msgtest`. The flag is fail-closed (older/unknown CLI omits it, because an unknown flag aborts startup and would kill every spawn) and allowlist-sanitized (a name of only unsafe characters is dropped), and the docker/remote builders never see it at all (`tmux-manager.ts:782-789`), which is why the `tmux` column stays the canonical join key @@ -196,8 +201,8 @@ idle: The contract an orchestrator follows for any fleet of two or more messaging workers. Every topology in the next section is this protocol plus a wiring diagram. -1. **Spawn with a name, and confirm hooks.** Use `quick-start` with `sessionName` (the - `--name` gate above). Session create installs the hooks block into the workspace +1. **Spawn with a name, and confirm hooks.** Use `quick-start` with a descriptive, + non-`w<N>-` `sessionName` (the `--name` gate above). Session create installs the hooks block into the workspace whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get `stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn `workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 1f0dceea..46eecc8d 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -692,8 +692,9 @@ The shape, each step verified live (probes, failure modes and safety detail in [§5.2](#52-readiness)). 2. `ListAgents`: find the worker's row by its `tmux codeman-<first 8 of session id>` column; the row's `name [ref]` is the address. On Codeman 1.16+ with claude - 2.1.224+ a worker's peer name is its Codeman session name, so pass `sessionName` - in quick-start to pick it; older setups list a name derived from the case folder. + 2.1.224+ a worker's peer name is its Codeman session name, so pass a DESCRIPTIVE + `sessionName` in quick-start to pick it (a `w<N>-` placeholder-shaped name is not + pinned, so it lists derived); older setups list a name derived from the case folder. No row = messaging is off for that worker (it is feature-flagged even on matching CLI versions, observed live): fall back to the HTTP recipes without complaint. 3. `SendMessage` the task; first contact must use the `name [ref]` form copied from diff --git a/src/claude-session-title.ts b/src/claude-session-title.ts new file mode 100644 index 00000000..f0e8c88a --- /dev/null +++ b/src/claude-session-title.ts @@ -0,0 +1,45 @@ +/** + * @fileoverview Carry a Codeman rename into Claude Code's own session title. + * + * Claude Code keeps a conversation's title in its transcript as a + * `{"type":"custom-title"}` row (what `/rename` writes), last row wins, and the + * `/resume` picker shows `customTitle ?? aiTitle`. Renaming a tab in Codeman + * used to change only the tab, so `/resume` kept listing the old name. + * + * Appending the row is enough for a pane that was spawned WITHOUT `--name` + * (every placeholder- or auto-named tab, see `Session.cliPinnedName`): that + * process holds no title of its own and never writes one back. A process that + * WAS spawned with `--name` re-appends its in-memory title after each turn, so + * there the new title holds from the next spawn, which pins the new name. + * + * @module claude-session-title + */ + +import fs from 'node:fs/promises'; + +/** + * Append a `custom-title` row for `conversationId` to an existing transcript. + * Never creates the file: a missing transcript means the conversation has not + * been written yet, and a file of only a title row would show up in `/resume` + * as an empty conversation. Returns whether a row was written. + */ +export async function appendClaudeCustomTitle( + transcriptPath: string, + conversationId: string, + title: string +): Promise<boolean> { + const customTitle = title.trim(); + // Claude reads the row through `customTitle ?? aiTitle`, so an empty string + // would blank the picker entry rather than fall back to the generated title. + if (!customTitle) return false; + try { + if (!(await fs.stat(transcriptPath)).isFile()) return false; + } catch { + return false; + } + // One O_APPEND write of one line, the same way Claude appends its own rows, + // so it cannot interleave with a row the live process is writing. + const row = JSON.stringify({ type: 'custom-title', customTitle, sessionId: conversationId }); + await fs.appendFile(transcriptPath, `${row}\n`); + return true; +} diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 058db3c6..f5d3124e 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -90,6 +90,13 @@ export interface CreateSessionOptions { workingDir: string; mode: SessionMode; name?: string; + /** + * Name pinned on a claude spawn as `--name` (version-gated, sanitized, local only). + * Deliberately NOT `name`: `--name` owns the prompt-box label, the `/resume` picker + * entry and the terminal title, and a pinned title stops Claude generating its own, + * so only a user-chosen name belongs here (see `Session.cliPinnedName`). + */ + cliName?: string; niceConfig?: NiceConfig; model?: string; claudeMode?: ClaudeMode; @@ -123,8 +130,15 @@ export interface RespawnPaneOptions { sessionId: string; workingDir: string; mode: SessionMode; - /** Session display name; a respawned claude keeps its `--name` peer name (version-gated, local only). */ + /** Session display name (tab name). */ name?: string; + /** + * Name pinned on a respawned claude as `--name` (version-gated, sanitized, local only). + * Deliberately NOT `name`: `--name` owns the prompt-box label, the `/resume` picker + * entry and the terminal title, and a pinned title stops Claude generating its own, + * so only a user-chosen name belongs here (see `Session.cliPinnedName`). + */ + cliName?: string; niceConfig?: NiceConfig; model?: string; claudeMode?: ClaudeMode; diff --git a/src/session.ts b/src/session.ts index ea8b22f7..d5e6ba16 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1560,6 +1560,19 @@ export class Session extends EventEmitter { return this._nameSource; } + /** + * The name to pin on the Claude CLI as `--name`, or undefined to let Claude + * title the conversation itself. `--name` is the prompt-box label, the + * `/resume` picker entry and the terminal title all at once, and a pinned + * title stops Claude generating its own, so only a name the user chose is + * worth pinning. Pinning the `w1-myapp` placeholder gave every conversation + * in a case the same `/resume` entry; an auto name is a cut of the first + * prompt, which Claude's own generated title already beats. + */ + get cliPinnedName(): string | undefined { + return this._nameSource === 'manual' ? this._name : undefined; + } + setAutoClear(enabled: boolean, threshold?: number): void { this._autoOps.setAutoClear(enabled, threshold); } @@ -2093,6 +2106,7 @@ export class Session extends EventEmitter { workingDir: this.workingDir, mode: this.mode, name: this._name, + cliName: this.cliPinnedName, niceConfig: this._niceConfig, model: this._model, claudeMode: this._claudeMode, @@ -2561,6 +2575,7 @@ export class Session extends EventEmitter { workingDir: this.workingDir, mode: this.mode, name: this._name, + cliName: this.cliPinnedName, niceConfig: this._niceConfig, model: this._model, claudeMode: this._claudeMode, @@ -2699,7 +2714,7 @@ export class Session extends EventEmitter { this._model, this._allowedTools, this._effort, - this._name, + this.cliPinnedName, getClaudeCliVersion() ); this.ptyProcess = spawnPtyWithHelperRepair(() => diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 2421c106..a0ad8d76 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -871,7 +871,7 @@ export function buildSpawnCommand(options: { effort?: EffortLevel; /** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */ statusLineCommand?: string; - /** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */ + /** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). Only a user-chosen name: see `Session.cliPinnedName`. */ sessionName?: string; /** * Claude CLI version for the `--name` gate. Omitted = probe the local CLI @@ -2054,6 +2054,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { workingDir, mode, name, + cliName, niceConfig, model, claudeMode, @@ -2157,7 +2158,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { resumeSessionId, effort, statusLineCommand, - sessionName: name, + sessionName: cliName, }); const config = niceConfig || DEFAULT_NICE_CONFIG; @@ -2385,7 +2386,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { effort, remote, docker, - name, + cliName, } = options; const session = this.sessions.get(sessionId); if (!session) return null; @@ -2422,7 +2423,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { resumeSessionId, effort, statusLineCommand, - sessionName: name, + sessionName: cliName, }); const config = niceConfig || DEFAULT_NICE_CONFIG; const cmd = wrapWithNice(baseCmd, config); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 997624f4..fe0d05e8 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -176,6 +176,7 @@ import { selectLastAnsweredTurn, } from '../response-viewer-transcript.js'; import { readDeepSeekLastResponse } from '../../deepseek-transcript.js'; +import { appendClaudeCustomTitle } from '../../claude-session-title.js'; // Path to linked-cases registry (same file used by case-routes resolveCasePath) const LINKED_CASES_FILE = dataPath('linked-cases.json'); @@ -1162,10 +1163,14 @@ export function registerSessionRoutes( const session = findSessionOrFail(ctx, id, req); const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH); + // A no-op rename (the Session Options name field saves on blur and recomposes the same + // string) must not flip nameSource to 'manual' or append a custom-title row to the transcript. + if (name === session.name) return { name: session.name }; session.name = name; // Also update the mux session name if applicable ctx.mux.updateSessionName(id, session.name); persistAndBroadcastSession(ctx, session); + await syncClaudeTitle(session); return { name: session.name }; }); @@ -2392,6 +2397,26 @@ export function registerSessionRoutes( return full ? { text: lastText, timestamp: lastTimestamp, messages } : { text: lastText, timestamp: lastTimestamp }; } + /** + * Mirror a rename into the conversation's `/resume` title (claude-session-title.ts). + * Local Claude-format transcripts only: a remote pane's transcript lives on the remote host and a + * docker pane's inside the container (HOME=/home/agent), never under the host's projects dir. Best + * effort: the tab rename has already happened and must not fail on this. + */ + async function syncClaudeTitle(session: Session): Promise<void> { + if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl' || session.remote || session.docker) return; + try { + const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects'); + const hookPath = ctx.getTranscriptPath(session.id); + const transcript = hookPath + ? { sessionId: basename(hookPath, '.jsonl'), path: hookPath } + : await findClaudeTranscript(projectsDir, session.claudeSessionId || session.id, session.id); + if (transcript) await appendClaudeCustomTitle(transcript.path, transcript.sessionId, session.name); + } catch (err) { + console.warn(`[Session] Could not carry rename into the Claude transcript for ${session.id}:`, err); + } + } + /** Locate a top-level Claude transcript, including recovered tmux sessions. */ async function findClaudeTranscript( projectsDir: string, diff --git a/test/claude-resume-title.test.ts b/test/claude-resume-title.test.ts new file mode 100644 index 00000000..6f477e6a --- /dev/null +++ b/test/claude-resume-title.test.ts @@ -0,0 +1,84 @@ +/** + * @fileoverview Claude's `/resume` title belongs to Claude unless the user chose one. + * + * `--name` sets the prompt-box label, the `/resume` picker entry and the terminal + * title, and a pinned title stops Claude generating its own. Pinning the + * `w1-myapp` placeholder therefore listed every conversation of a case under the + * same name in `/resume`. Only a manual name is pinned now (`cliPinnedName`), and + * a rename reaches the transcript as a `custom-title` row. + */ + +import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Session } from '../src/session.js'; +import { appendClaudeCustomTitle } from '../src/claude-session-title.js'; + +type RespawnOptionsProbe = { _buildRespawnPaneOptions(): { name?: string; cliName?: string } }; + +describe('Session.cliPinnedName', () => { + it('pins nothing for a placeholder, so Claude titles the conversation itself', () => { + const session = new Session({ workingDir: '/tmp', name: 'w1-demo' }); + expect(session.cliPinnedName).toBeUndefined(); + const options = (session as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions(); + // The tab keeps its name; only the CLI flag is withheld. + expect(options.name).toBe('w1-demo'); + expect(options.cliName).toBeUndefined(); + }); + + it('pins nothing for an auto name, whose cut of the prompt Claude beats', () => { + const session = new Session({ workingDir: '/tmp', name: 'w1-demo' }); + expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true); + expect(session.cliPinnedName).toBeUndefined(); + }); + + it('pins a name the user chose, at creation or by a rename', () => { + expect(new Session({ workingDir: '/tmp', name: 'msgtest-worker' }).cliPinnedName).toBe('msgtest-worker'); + + const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' }); + renamed.name = '登录修复'; + expect(renamed.cliPinnedName).toBe('登录修复'); + expect((renamed as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions().cliName).toBe('登录修复'); + }); +}); + +describe('appendClaudeCustomTitle', () => { + const dirs: string[] = []; + afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + const tempTranscript = (content: string) => { + const dir = mkdtempSync(join(tmpdir(), 'codeman-claude-title-')); + dirs.push(dir); + const path = join(dir, 'conv.jsonl'); + if (content) writeFileSync(path, content); + return path; + }; + + it('appends one custom-title row after the existing rows', async () => { + const path = tempTranscript('{"type":"user"}\n'); + expect(await appendClaudeCustomTitle(path, 'conv', ' release notes "v2" ')).toBe(true); + const lines = readFileSync(path, 'utf8').split('\n'); + expect(lines).toHaveLength(3); + expect(lines[0]).toBe('{"type":"user"}'); + expect(JSON.parse(lines[1])).toEqual({ + type: 'custom-title', + customTitle: 'release notes "v2"', + sessionId: 'conv', + }); + expect(lines[2]).toBe(''); + }); + + it('never creates a transcript that does not exist yet', async () => { + const path = tempTranscript(''); + expect(await appendClaudeCustomTitle(path, 'conv', 'title')).toBe(false); + expect(existsSync(path)).toBe(false); + }); + + it('writes nothing for a blank title', async () => { + const path = tempTranscript('{"type":"user"}\n'); + expect(await appendClaudeCustomTitle(path, 'conv', ' ')).toBe(false); + expect(readFileSync(path, 'utf8')).toBe('{"type":"user"}\n'); + }); +}); diff --git a/test/routes/session-name-routes.test.ts b/test/routes/session-name-routes.test.ts index ba161759..5bdc53e7 100644 --- a/test/routes/session-name-routes.test.ts +++ b/test/routes/session-name-routes.test.ts @@ -5,10 +5,16 @@ * auto-naming can never overwrite a name a person chose, on this server or * on the one that restores the session after a restart. * + * The rename also reaches Claude's own `/resume` title: a `custom-title` row is + * appended to the conversation's transcript, the row `/rename` writes. + * * Uses app.inject() — no real HTTP ports needed. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { Session } from '../../src/session.js'; @@ -18,17 +24,45 @@ describe('PUT /api/sessions/:id/name', () => { let harness: RouteTestHarness; let session: Session; const updateSessionName = vi.fn(() => true); + const transcriptDir = mkdtempSync(join(tmpdir(), 'codeman-rename-title-')); + const transcriptPath = join(transcriptDir, '6f1c1a2e-0000-4000-8000-000000000001.jsonl'); + const transcriptRows = () => + readFileSync(transcriptPath, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as Record<string, unknown>); beforeAll(async () => { + writeFileSync(transcriptPath, `${JSON.stringify({ type: 'user', message: { role: 'user', content: 'hi' } })}\n`); harness = await createRouteTestHarness(registerSessionRoutes); // A REAL session, since the ownership flag lives on the class, not the mock. session = new Session({ id: 'name-route-test', workingDir: '/tmp', name: 'w1-demo' }); harness.ctx.sessions.set(session.id, session as never); (harness.ctx.mux as Record<string, unknown>).updateSessionName = updateSessionName; + (harness.ctx as Record<string, unknown>).getTranscriptPath = (id: string) => + id === session.id ? transcriptPath : null; }); afterAll(async () => { await harness.app.close(); + rmSync(transcriptDir, { recursive: true, force: true }); + }); + + it('treats a same-name PUT as a no-op: stays placeholder, writes no title row', async () => { + // The Session Options field saves on blur and recomposes the unchanged placeholder. + const before = transcriptRows().length; + const res = await harness.app.inject({ + method: 'PUT', + url: `/api/sessions/${session.id}/name`, + payload: { name: 'w1-demo' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ name: 'w1-demo' }); + expect(session.nameSource).toBe('placeholder'); + expect(transcriptRows()).toHaveLength(before); + expect(updateSessionName).not.toHaveBeenCalled(); + expect(harness.ctx.persistSessionState).not.toHaveBeenCalled(); }); it('flips a placeholder to manual, then persists and broadcasts the ownership', async () => { @@ -57,4 +91,60 @@ describe('PUT /api/sessions/:id/name', () => { // What the restore path will read back: the persisted state carries the flag. expect(session.toState().nameSource).toBe('manual'); }); + + it("appends the name as the conversation's custom-title, the row /resume reads", async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: `/api/sessions/${session.id}/name`, + payload: { name: '修复登录跳转' }, + }); + + expect(res.statusCode).toBe(200); + expect(transcriptRows().at(-1)).toEqual({ + type: 'custom-title', + customTitle: '修复登录跳转', + sessionId: '6f1c1a2e-0000-4000-8000-000000000001', + }); + }); + + it('writes no title row for an empty name, which would blank the /resume entry', async () => { + const before = transcriptRows().length; + const res = await harness.app.inject({ + method: 'PUT', + url: `/api/sessions/${session.id}/name`, + payload: { name: ' ' }, + }); + + expect(res.statusCode).toBe(200); + expect(transcriptRows()).toHaveLength(before); + }); + + it('appends a title row only once when the same name is PUT twice', async () => { + const before = transcriptRows().length; + for (let i = 0; i < 2; i++) { + await harness.app.inject({ + method: 'PUT', + url: `/api/sessions/${session.id}/name`, + payload: { name: 'twice' }, + }); + } + expect(transcriptRows()).toHaveLength(before + 1); + }); + + it('writes no title row for a docker session, whose transcript lives in the container', async () => { + const before = transcriptRows().length; + Object.defineProperty(session, 'docker', { configurable: true, get: () => ({ caseName: 'c' }) }); + try { + const res = await harness.app.inject({ + method: 'PUT', + url: `/api/sessions/${session.id}/name`, + payload: { name: 'in a container' }, + }); + expect(res.statusCode).toBe(200); + expect(session.name).toBe('in a container'); + expect(transcriptRows()).toHaveLength(before); + } finally { + delete (session as unknown as Record<string, unknown>).docker; + } + }); });