diff --git a/.changeset/cli-session-name-matches-tab.md b/.changeset/cli-session-name-matches-tab.md index 26b0e08b..66473bf7 100644 --- a/.changeset/cli-session-name-matches-tab.md +++ b/.changeset/cli-session-name-matches-tab.md @@ -2,4 +2,4 @@ 'aicodeman': minor --- -A Claude session is now launched with `--name ` for every tab, not only for ones you renamed. Claude Code's prompt-box label, `/resume` entry and its remote session list used to show Claude's own generated title (for example "Git indicator guard test and teardown flake") for a tab that Codeman called `w4-Codeman`; they now read the same as the tab. A rename or an auto-name reaches the CLI at its next spawn. +New App Settings option, "Pin tab names on the CLI" (off by default): launch every Claude session with `--name `, not only the ones you renamed, so Claude Code's prompt-box label, `/resume` entry and remote session list read the same as the tab instead of Claude's own generated title. With it off nothing changes. The cost: Claude stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn (an auto-name reaches the CLI at its next spawn). The setting is read at each session start and respawn, so a toggle applies without a server restart. diff --git a/CLAUDE.md b/CLAUDE.md index 76422a7c..2b3380a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -182,7 +182,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md` -**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. ⚠️ `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) +**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`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; 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 306c92e6..82d0e965 100644 --- a/docs/agent-control-plan.md +++ b/docs/agent-control-plan.md @@ -761,9 +761,12 @@ 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`, +name in `/resume`. Only a manual name is pinned by default (`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 +names leave Claude to title the conversation. The opt-in synced `pinCliSessionName` +(default OFF, re-read at every spawn and respawn) pins every tab name instead, at the +cost of Claude's generated titles; a rename then holds only until the next turn, because +a pane spawned with `--name` re-appends its in-memory title. 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 896e8d73..9d1107c1 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -203,7 +203,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str **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. +**`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 by default only a `manual` name is passed (`Session.cliPinnedName`); pinning the `w1-myapp` placeholder gave every conversation of a case the same `/resume` entry, because `_nextCaseSessionStartNumber()` numbers from the highest OPEN tab and restarts once a case's tabs are closed. The opt-in synced `pinCliSessionName` (default OFF) pins every non-blank tab name for users who want the CLI labels to match their tabs; it is read fresh at every claude create and respawn (`readPinCliSessionNameEnabled()`, hooks-config.ts, like `readPlanUsageTelemetryEnabled()`), so a toggle applies from the next spawn with no restart. ⚠️ Its cost: Claude stops generating its own titles for those sessions, and a rename holds only until the next turn, because a pane spawned with `--name` re-appends its in-memory title after every turn (`claude-session-title.ts`); a server restart re-attaches the live pane without respawning it. 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`. diff --git a/plugins/codeman/skills/codeman/reference/messaging.md b/plugins/codeman/skills/codeman/reference/messaging.md index 2d7d68d0..a1de12a5 100644 --- a/plugins/codeman/skills/codeman/reference/messaging.md +++ b/plugins/codeman/skills/codeman/reference/messaging.md @@ -105,11 +105,11 @@ CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in a 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. ⚠️ Give them a DESCRIPTIVE name: only a name the user chose -is pinned (`Session.cliPinnedName`), because `--name` is also the conversation's +peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: by default only a name the user chose +is pinned (`Session.cliPinnedName`; the opt-in `pinCliSessionName` setting pins every tab name, and a rename then holds only until the next turn), 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 +and an auto name are NOT passed (with `pinCliSessionName` off, the default), 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 diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 690cdbf3..ef459805 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -699,7 +699,7 @@ The shape, each step verified live (probes, failure modes and safety detail in 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 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. + pinned by default, 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/messaging.md b/skills/codeman/reference/messaging.md index 2d7d68d0..a1de12a5 100644 --- a/skills/codeman/reference/messaging.md +++ b/skills/codeman/reference/messaging.md @@ -105,11 +105,11 @@ CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in a 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. ⚠️ Give them a DESCRIPTIVE name: only a name the user chose -is pinned (`Session.cliPinnedName`), because `--name` is also the conversation's +peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: by default only a name the user chose +is pinned (`Session.cliPinnedName`; the opt-in `pinCliSessionName` setting pins every tab name, and a rename then holds only until the next turn), 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 +and an auto name are NOT passed (with `pinCliSessionName` off, the default), 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 diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 690cdbf3..ef459805 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -699,7 +699,7 @@ The shape, each step verified live (probes, failure modes and safety detail in 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 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. + pinned by default, 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 index f0e8c88a..f6be344c 100644 --- a/src/claude-session-title.ts +++ b/src/claude-session-title.ts @@ -7,10 +7,12 @@ * 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. + * (every placeholder- or auto-named tab, unless the opt-in `pinCliSessionName` + * setting is on, see `Session.cliPinnedName`): that process holds no title of + * its own and never writes one back. A process that WAS spawned with `--name` + * (every tab with `pinCliSessionName` on) re-appends its in-memory title after + * each turn, so there the new title holds only until the next turn, then from + * the next spawn, which pins the new name. * * @module claude-session-title */ diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 99b63219..83f838eb 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -1131,6 +1131,19 @@ export async function readPlanUsageTelemetryEnabled(): Promise<boolean> { return settings.showPlanUsageLimits !== false; } +/** + * Whether every Claude session is launched with `--name` set to its tab name + * (the `pinCliSessionName` setting). Read FRESH from settings.json at every + * claude create and respawn, like {@link readPlanUsageTelemetryEnabled}, so a + * toggle applies from the next spawn with no server restart. An ABSENT key + * means OFF: only a name the user chose is pinned, and Claude titles the + * placeholder- and auto-named conversations itself (see `Session.cliPinnedName`). + */ +export async function readPinCliSessionNameEnabled(): Promise<boolean> { + const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {}); + return settings.pinCliSessionName === true; +} + /** * Resolve the statusLine command to pass as an EPHEMERAL `claude --settings` * CLI flag for this one process (see buildSpawnCommandFromRegistry in diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 1283281c..cf59700f 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -97,7 +97,8 @@ export interface CreateSessionOptions { * 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`). + * so by default only a user-chosen name belongs here; the opt-in `pinCliSessionName` + * setting pins every tab name (see `Session.cliPinnedName`). */ cliName?: string; niceConfig?: NiceConfig; @@ -141,7 +142,8 @@ export interface RespawnPaneOptions { * 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`). + * so by default only a user-chosen name belongs here; the opt-in `pinCliSessionName` + * setting pins every tab name (see `Session.cliPinnedName`). */ cliName?: string; niceConfig?: NiceConfig; diff --git a/src/session.ts b/src/session.ts index 92809e49..63149ad8 100644 --- a/src/session.ts +++ b/src/session.ts @@ -499,6 +499,8 @@ export class Session extends EventEmitter { private _name: string; private _nameSource: SessionNameSource; + /** `pinCliSessionName` as of the last spawn (see {@link _refreshCliNamePin}). */ + private _pinEveryCliName = false; /** * Reconstructs the composer draft from USER keystrokes so the first real * prompt can name the tab. Fed only when a write says `fromUser`, and never @@ -1806,21 +1808,33 @@ export class Session extends EventEmitter { } /** - * The name to pin on the Claude CLI as `--name`: the tab's own name, whatever its source, - * so the CLI's prompt-box label, `/resume` entry and terminal title (and Claude Code's - * remote session list) read the same as the tab. Leaving a placeholder or auto-named tab - * unpinned let Claude generate its own title, which then differed from the tab it was - * running in (`w4-myapp` in Codeman, "Fix the flaky teardown" in Claude). + * 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. * - * The cost is that every conversation of a case starts with the same `/resume` entry shape - * (`w1-myapp`, `w2-myapp`); the tab number keeps those distinct per tab. A rename or an - * auto-name reaches the CLI at the next spawn: a process started with `--name` re-appends its - * in-memory title after each turn (see claude-session-title.ts). + * By default only a name the user chose is pinned. Pinning the `w1-myapp` placeholder gave + * every conversation in a case the same `/resume` entry (the tab number restarts once a + * case's tabs are closed), and an auto name is a cut of the first prompt, which Claude's + * own generated title already beats. + * + * With the opt-in `pinCliSessionName` setting every non-blank tab name is pinned, so the + * CLI's labels read the same as the tabs, at the cost of those generated titles. A rename + * in Codeman then holds only until the next turn: a process started with `--name` + * re-appends its in-memory title (see claude-session-title.ts). The setting is re-read + * at each spawn and respawn by {@link _refreshCliNamePin}. */ get cliPinnedName(): string | undefined { + if (!this._pinEveryCliName) return this._nameSource === 'manual' ? this._name : undefined; return this._name.trim() ? this._name : undefined; } + /** Re-read `pinCliSessionName` so `cliPinnedName` decides per launch, with no server restart. */ + private async _refreshCliNamePin(): Promise<void> { + // Dynamic: hooks-config -> route-helpers imports Session, so a static import would be a cycle. + const { readPinCliSessionNameEnabled } = await import('./hooks-config.js'); + this._pinEveryCliName = await readPinCliSessionNameEnabled(); + } + setAutoClear(enabled: boolean, threshold?: number): void { this._autoOps.setAutoClear(enabled, threshold); } @@ -2478,6 +2492,7 @@ export class Session extends EventEmitter { * still inside it and renders no local `--session-id` to collide. */ private async _buildRespawnPaneOptionsWithResumePin(): Promise<import('./mux-interface.js').RespawnPaneOptions> { + await this._refreshCliNamePin(); const options = this._buildRespawnPaneOptions(); if (this._remote || this._docker) return options; const entry = getCli(this.mode); @@ -2742,6 +2757,8 @@ export class Session extends EventEmitter { throw new Error('Session is being closed'); } + await this._refreshCliNamePin(); + // Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here // rather than at PTY spawn so a slow mux attach still counts as startup. this._interactiveStartedAt = Date.now(); diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 7adb662a..3c80aeca 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -1089,7 +1089,7 @@ export function buildSpawnCommand(options: { advisorModel?: string; /** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */ statusLineCommand?: string; - /** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). Only a user-chosen name: see `Session.cliPinnedName`. */ + /** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). By default only a user-chosen name; `pinCliSessionName` pins every tab name: see `Session.cliPinnedName`. */ sessionName?: string; /** * Claude CLI version for the `--name` gate. Omitted = probe the local CLI diff --git a/src/web/public/index.html b/src/web/public/index.html index 713182ee..01b94dff 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2787,6 +2787,13 @@ </div> <label class="switch switch-sm"><input type="checkbox" id="appSettingsWorkspaceHooks"><span class="slider"></span></label> </div> + <div class="set-row" data-search="pin cli session name resume title tab label"> + <div class="set-row-text"> + <span class="set-row-label">Pin tab names on the CLI</span> + <span class="set-row-desc">Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.</span> + </div> + <label class="switch switch-sm"><input type="checkbox" id="appSettingsPinCliSessionName"><span class="slider"></span></label> + </div> <div class="set-row" data-search="remote auto reconnect ssh"> <div class="set-row-text"> <span class="set-row-label">Remote auto-reconnect</span> diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 7c8cb2fc..e1f27a95 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -588,6 +588,7 @@ Object.assign(CodemanApp.prototype, { // Default ON: an absent key is a user who has never seen this setting, and OFF // for them means no tab alerts in any workspace Codeman did not scaffold. document.getElementById('appSettingsWorkspaceHooks').checked = settings.workspaceHooksEnabled !== false; + document.getElementById('appSettingsPinCliSessionName').checked = settings.pinCliSessionName === true; document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? ''; document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false; document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true; @@ -2708,6 +2709,7 @@ Object.assign(CodemanApp.prototype, { agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked, agentSkillEnabled: document.getElementById('appSettingsAgentSkill').checked, workspaceHooksEnabled: document.getElementById('appSettingsWorkspaceHooks').checked, + pinCliSessionName: document.getElementById('appSettingsPinCliSessionName').checked, claudeVoiceEnabled: document.getElementById('appSettingsClaudeVoice').checked, claudeModel: document.getElementById('appSettingsClaudeModel').value, opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked, diff --git a/src/web/schemas.ts b/src/web/schemas.ts index d7231743..c7e986d5 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1425,6 +1425,14 @@ export const SettingsUpdateSchema = z * never added — for a user who wants Codeman to leave their repos alone. */ workspaceHooksEnabled: z.boolean().optional(), + /** + * Launch every Claude session with `--name` set to its tab name (default OFF: only a + * name the user chose is pinned). Nothing acts on it in `PUT /api/settings`: it is read + * fresh from disk by readPinCliSessionNameEnabled() (hooks-config.ts) at every claude + * create and respawn, like readPlanUsageTelemetryEnabled(), so a toggle applies from the + * next spawn with no restart. + */ + pinCliSessionName: z.boolean().optional(), /** * Let browser dictation transcribe through this machine's Claude Code login, * the same speech-to-text service the CLI's own `/voice` mode uses diff --git a/test/claude-resume-title.test.ts b/test/claude-resume-title.test.ts index 3321db0a..d2653afd 100644 --- a/test/claude-resume-title.test.ts +++ b/test/claude-resume-title.test.ts @@ -1,43 +1,110 @@ /** - * @fileoverview The CLI's session name is the tab's name. + * @fileoverview The CLI's session name and the opt-in `pinCliSessionName` setting. * * `--name` sets the prompt-box label, the `/resume` picker entry and the terminal - * title, and a pinned title stops Claude generating its own. Every tab's name is pinned - * (`cliPinnedName`), placeholder and auto names included, so Claude's own session list - * matches the tab. A rename reaches the transcript as a `custom-title` row. + * title, and a pinned title stops Claude generating its own. By default only a name + * the user chose is pinned (`cliPinnedName`); with `pinCliSessionName` on, every tab's + * name is pinned, placeholder and auto names included, so Claude's own session list + * matches the tab. The setting is re-read at each spawn and respawn. A rename reaches + * the transcript as a `custom-title` row. */ -import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, 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'; +import { SETTINGS_PATH } from '../src/web/route-helpers.js'; -type RespawnOptionsProbe = { _buildRespawnPaneOptions(): { name?: string; cliName?: string } }; +type RespawnOptionsProbe = { + _buildRespawnPaneOptions(): { name?: string; cliName?: string }; + _buildRespawnPaneOptionsWithResumePin(): Promise<{ name?: string; cliName?: string }>; +}; + +const probe = (session: Session) => session as unknown as RespawnOptionsProbe; + +/** The respawn builder is the path every respawn takes; it re-reads the setting first. */ +const cliNameAtRespawn = async (session: Session) => + (await probe(session)._buildRespawnPaneOptionsWithResumePin()).cliName; + +function writeSettings(settings: Record<string, unknown> | null) { + if (settings === null) { + rmSync(SETTINGS_PATH, { force: true }); + return; + } + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify(settings)); +} describe('Session.cliPinnedName', () => { - it('pins the placeholder name, so the CLI session matches the tab', () => { - const session = new Session({ workingDir: '/tmp', name: 'w1-demo' }); - expect(session.cliPinnedName).toBe('w1-demo'); - const options = (session as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions(); - expect(options.name).toBe('w1-demo'); - expect(options.cliName).toBe('w1-demo'); + afterEach(() => writeSettings(null)); + + describe('pinCliSessionName off (default)', () => { + it.each([null, {}, { pinCliSessionName: false }])( + 'leaves a placeholder name unpinned (settings: %j)', + async (settings) => { + writeSettings(settings); + const session = new Session({ workingDir: '/tmp', name: 'w1-demo' }); + expect(await cliNameAtRespawn(session)).toBeUndefined(); + expect(session.cliPinnedName).toBeUndefined(); + expect(probe(session)._buildRespawnPaneOptions().name).toBe('w1-demo'); + } + ); + + it('leaves an auto name unpinned', async () => { + writeSettings({ pinCliSessionName: false }); + const session = new Session({ workingDir: '/tmp', name: 'w1-demo' }); + expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true); + expect(await cliNameAtRespawn(session)).toBeUndefined(); + }); + + it('still pins a name the user chose, at creation or by a rename', async () => { + writeSettings({ pinCliSessionName: false }); + expect(await cliNameAtRespawn(new Session({ workingDir: '/tmp', name: 'msgtest-worker' }))).toBe( + 'msgtest-worker' + ); + + const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' }); + renamed.name = '登录修复'; + expect(await cliNameAtRespawn(renamed)).toBe('登录修复'); + }); }); - it('pins an auto name too, from the next spawn', () => { - const session = new Session({ workingDir: '/tmp', name: 'w1-demo' }); - expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true); - expect(session.cliPinnedName).toBe('w1-demo: fix the login redirect'); + describe('pinCliSessionName on', () => { + it('pins the placeholder name, so the CLI session matches the tab', async () => { + writeSettings({ pinCliSessionName: true }); + const session = new Session({ workingDir: '/tmp', name: 'w1-demo' }); + expect(await cliNameAtRespawn(session)).toBe('w1-demo'); + expect(session.cliPinnedName).toBe('w1-demo'); + }); + + it('pins an auto name too, from the next spawn', async () => { + writeSettings({ pinCliSessionName: true }); + const session = new Session({ workingDir: '/tmp', name: 'w1-demo' }); + expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true); + expect(await cliNameAtRespawn(session)).toBe('w1-demo: fix the login redirect'); + }); + + it('pins a name the user chose', async () => { + writeSettings({ pinCliSessionName: true }); + const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' }); + renamed.name = '登录修复'; + expect(await cliNameAtRespawn(renamed)).toBe('登录修复'); + }); + + it('never pins a blank name', async () => { + writeSettings({ pinCliSessionName: true }); + expect(await cliNameAtRespawn(new Session({ workingDir: '/tmp', name: ' ' }))).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('登录修复'); + it('follows the setting on the next respawn of the same session, with no restart', async () => { + const session = new Session({ workingDir: '/tmp', name: 'w1-demo' }); + writeSettings({ pinCliSessionName: true }); + expect(await cliNameAtRespawn(session)).toBe('w1-demo'); + writeSettings({ pinCliSessionName: false }); + expect(await cliNameAtRespawn(session)).toBeUndefined(); }); });