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
+2
View File
@@ -233,6 +233,8 @@ 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 the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:<id>` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels. ⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every arc leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2" — per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the arcs below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker), and set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:<childId>"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest.
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF; #376): a placeholder tab (`w3-myapp`) takes its first real prompt as a title, in the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`) that `parseSessionPrefix()` (app.js, #232) already renders as the title alone with the prefix in the tooltip and that `_nextCaseSessionStartNumber()` still counts, so the case identity and the `w<n>` counter survive. Ownership is the tri-state `SessionState.nameSource`: `placeholder` (Codeman's own `w<n>-<case>` or no name, inferred by `isGeneratedSessionName()` when a persisted state predates the field), `auto` (titled once), `manual` (the `name` setter, i.e. `PUT /api/sessions/:id/name`, which auto-naming never touches again). ⚠️ **First prompt means the FIRST**: `applyAutoName()` flips a placeholder to `auto` whether or not the string changed, so a later "1" cannot rename the tab; a prompt that yields no title (`/clear`, a `!` shell escape) leaves the session eligible for the next one. ⚠️ **Only user-originated input counts** (`SessionWriteOptions.fromUser`, set by the browser WS path and `POST /api/sessions/:id/input` ONLY, so a forgotten flag on a new path fails toward not naming): Ralph kick-starts, respawn `/clear`s, cron launches, approval answers and the trust-dialog keys write through the same `write()`/`writeViaMux()` and used to name every Ralph tab "Read @ralph_prompt.md…". A `startMode: 'shell'` CLI never feeds the tracker (a capability, not an id check; a shell tab was renamed after every `ls`), and the send-key route's Shift+Enter line feed bypasses the session entirely, so it calls `trackUserInput()` or the two lines join with no separator. ⚠️ **The tracker (`session-auto-name.ts`, pure) sits on the raw keystroke stream**, so every key has an explicit rule: a bare Esc is resolved at the END of the chunk it arrives in (it used to stay in escape mode and eat the next prompt's first character, or a whole CJK prompt); SGR mouse reports, Tab, cursor keys and Shift+Tab leave the draft alone (a wheel tick mid-word used to drop the first half); Up/Down and Ctrl+P/N/R TAINT the draft so Enter submits nothing rather than a fragment; bracketed-paste newlines are newlines IN the composer, never Enter. The title is the first sentence past a minimum length ("e.g. fix this now" is not "e.g."), capped at 72 code points, and the composed name honours `MAX_SESSION_NAME_LENGTH`. The listener (`session-listener-wiring.ts`) checks eligibility BEFORE reading the setting, so an already-named session costs no settings read per prompt. Opt-in because the prompt lands in the tab name, `mux-sessions.json`, every `session:updated` and `/api/search` (Read My Mind keeps prompts 0600 for the same reason). Tests: `test/session-auto-name.test.ts`, `test/session-listener-wiring.test.ts`, `test/routes/session-name-routes.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.
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). ⚠️ **Transcript history is THREE stores, not one**, because each CLI keeps its conversations in its own: Claude's `~/.claude/projects`, omp's `~/.omp/agent/sessions` and codex's `~/.codex/sessions` (#386). Rows fold into their owning session via the `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice; that field is named for Claude and carries whatever id the CLI names its conversation with, which for every non-Claude row diverges from the Codeman id by construction. ⚠️ **`resumeId` is set by a SCANNER row only, never by a live session**, and that is what makes it safe to resume on: a row carrying one is a conversation already on disk, so `resumeHistorySession()` sends `codexConfig.resumeSessionId` and a row without one is a genuinely fresh session. Every surface that re-projects these rows has to carry the field through, the phone overview included, or a tap on that surface silently starts a second conversation. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)