feat(sessions): land auto-naming opt-in, in the prefix form, from the first user prompt only

Finishes #376. The contributed keystroke tracker sat on the raw byte stream
and named tabs wrong five ways (every prompt, every write path, a bare Esc
eating the next prompt's first character, pasted newlines as Enter, any CSI
clearing the draft) and replaced the whole name, which dropped the case from
the tab and reset the w<n> counter. This lands the feature with each of those
closed:

- First prompt means the first: applyAutoName() flips a placeholder to
  `auto` whether or not the string changed. nameSource is now the tri-state
  placeholder | auto | manual; the name setter is the only manual path.
- Only user-originated input counts: write()/writeViaMux() take
  SessionWriteOptions.fromUser, set by the browser WS path and POST /input
  only, so Ralph, respawn, cron, approvals and the trust-dialog keys can
  never name a tab. A startMode 'shell' CLI never feeds the tracker (a
  capability, not an id check); the send-key route feeds trackUserInput()
  because its line feed bypasses the session.
- Prefix form `w3-case: title`: parseSessionPrefix() already renders it as
  the title with the prefix in the tooltip and the next-session counter
  still matches it. Composed within MAX_SESSION_NAME_LENGTH.
- Tracker rules per key: bare Esc resolves at chunk end; mouse/focus
  reports, Tab, cursor keys, Shift+Tab are no-ops; Up/Down and Ctrl+P/N/R
  taint the draft so Enter submits nothing rather than a fragment;
  bracketed-paste newlines and Ctrl+J / Shift+Enter join with one space;
  the draft keeps its head past 8192 code points; an escape past 64 bytes
  is abandoned.
- Title: slash commands by shape (a path is a prompt), `!` escapes
  refused, first sentence only past 8 code points ("e.g." is not a title),
  72 code points on a word boundary.
- Synced `autoNameSessions` setting, default OFF (the prompt reaches
  mux-sessions.json, session:updated and /api/search), App Settings ->
  Appearance -> Tabs, read fresh per prompt after the eligibility check.

Tests: test/session-auto-name.test.ts (tracker, title, composition,
ownership, emit gating), the wiring test (once, prefix, setting off,
manual protected), test/routes/session-name-routes.test.ts (PUT /name
flips to manual and persists). Verified live on an isolated instance: API
and browser-typed prompts name the tab, a second prompt does not, shells
and renamed tabs are untouched, nameSource survives a restart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-15 17:59:16 +02:00
parent c4322513d9
commit 5b920cb43d
21 changed files with 874 additions and 149 deletions
+18 -3
View File
@@ -58,8 +58,23 @@ export type SessionMode =
| 'deepseek'
| 'omp';
/** Whether a session name may still be replaced by the first submitted prompt. */
export type SessionNameSource = 'auto' | 'manual';
/**
* Who owns a session's name. `placeholder`: Codeman's own `w<n>-<case>` (or no
* name at all), still eligible for auto-naming. `auto`: titled after its first
* prompt (`w<n>-<case>: <title>`), which happens once. `manual`: set by a
* person; auto-naming never touches it.
*/
export type SessionNameSource = 'placeholder' | 'auto' | 'manual';
/** Options for `Session.write()` / `Session.writeViaMux()`. */
export interface SessionWriteOptions {
/**
* The bytes were typed by a person, or sent by an agent on their behalf
* (browser keystrokes, `POST /api/sessions/:id/input`). Only such input can
* name a tab; Ralph, respawn, cron and approval writes leave this unset.
*/
fromUser?: boolean;
}
export type RemoteCommandMode = Extract<
SessionMode,
@@ -617,7 +632,7 @@ export interface SessionState {
lastActivityAt: number;
/** Session display name */
name?: string;
/** Name ownership; auto names are replaced after the first real prompt. */
/** Who owns the name (see `SessionNameSource`); absent on states persisted before auto-naming existed. */
nameSource?: SessionNameSource;
/** Session mode */
mode?: SessionMode;