mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
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:
+315
-69
@@ -1,101 +1,347 @@
|
||||
/**
|
||||
* Helpers for assigning a useful default name after the first submitted prompt.
|
||||
* @fileoverview Automatic session names from the first prompt.
|
||||
*
|
||||
* This deliberately does not call an LLM: the prompt is already available at
|
||||
* the input boundary, so a bounded local title is private, deterministic, and
|
||||
* works for every CLI backend.
|
||||
* A new tab is born as `w3-myapp`, which says where it runs and nothing about
|
||||
* what it is doing. Once the user submits a real prompt the tab can carry a
|
||||
* title derived from it (`w3-myapp: fix the login redirect`), and this module
|
||||
* holds the three pure pieces of that: a tracker that reconstructs the composer
|
||||
* text from the keystrokes Codeman forwards, the title heuristic, and the
|
||||
* prefix-preserving composition.
|
||||
*
|
||||
* Deliberately no LLM: the prompt already passes through the input boundary,
|
||||
* so a local title is private, deterministic and identical for every CLI.
|
||||
*
|
||||
* ⚠️ The tracker sits on the raw keystroke stream, which carries far more than
|
||||
* the prompt: cursor keys, mouse reports Codeman forwards to the CLI, bracketed
|
||||
* pastes, Alt chords, the bare Esc that interrupts a turn. Every one of those
|
||||
* once named a tab something wrong (a lone Esc ate the next prompt's first
|
||||
* character; a wheel tick mid-word dropped the first half of the prompt), so
|
||||
* the rules below are explicit per key. The model is a best-effort transcript:
|
||||
* keys whose effect on the composer is knowable are mirrored, keys that leave
|
||||
* the text alone are ignored, and keys that replace it with something the
|
||||
* tracker cannot see (history recall) TAINT the draft so that Enter submits
|
||||
* nothing rather than a fragment. A prompt that yields no title leaves the
|
||||
* session eligible for the next one.
|
||||
*
|
||||
* Only user-originated input is fed here; the Session decides that. Ralph
|
||||
* kick-starts, respawn `/clear`s, cron launches and approval answers all go
|
||||
* through the same write paths and must never become a tab title.
|
||||
*
|
||||
* @module session-auto-name
|
||||
*/
|
||||
|
||||
const MAX_PROMPT_BUFFER_LENGTH = 8_192;
|
||||
import { MAX_SESSION_NAME_LENGTH } from './config/terminal-limits.js';
|
||||
|
||||
/**
|
||||
* Longest composer draft kept, in code points. The title is cut from the HEAD
|
||||
* of the prompt, so once the cap is reached further text is counted rather
|
||||
* than kept (backspaces consume that count first). Keeping the tail instead
|
||||
* would turn a long paste into a title made of its last line.
|
||||
*/
|
||||
const MAX_PROMPT_BUFFER_CODE_POINTS = 8_192;
|
||||
|
||||
/** Longest escape sequence collected before the tracker gives up on it. */
|
||||
const MAX_ESCAPE_SEQUENCE_LENGTH = 64;
|
||||
|
||||
/** Longest title, in code points, before it is cut with an ellipsis. */
|
||||
const MAX_AUTO_NAME_CODE_POINTS = 72;
|
||||
|
||||
/**
|
||||
* Tracks terminal input until Enter is received. Terminal input arrives in
|
||||
* arbitrary chunks, so this keeps only a small composer buffer and ignores
|
||||
* navigation/control escape sequences.
|
||||
* A sentence boundary is only honoured this far into the prompt, or "e.g. fix
|
||||
* this now" becomes "e.g." and "Ok. Fix the bug" becomes "Ok". Short enough
|
||||
* that a CJK sentence (a dozen code points is a full request) still cuts.
|
||||
*/
|
||||
const MIN_SENTENCE_CODE_POINTS = 8;
|
||||
|
||||
/** A CSI sequence ends at its first byte in this range. */
|
||||
const CSI_FINAL_BYTE = /[\x40-\x7e]/;
|
||||
/** CSI parameter and intermediate bytes; anything else mid-sequence is malformed. */
|
||||
const CSI_BODY_BYTE = /[\x20-\x3f]/;
|
||||
|
||||
/**
|
||||
* `/clear`, `/model opus`, `/ralph-loop:ralph-loop`: a slash followed by a
|
||||
* command word and then whitespace or the end. A path (`/home/me/notes.txt
|
||||
* what is this`) has a second slash where the whitespace should be and so is a
|
||||
* prompt.
|
||||
*/
|
||||
const SLASH_COMMAND_PATTERN = /^\/[a-z][a-z0-9_:-]*(?:\s|$)/i;
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CSI_SEQUENCE_PATTERN = /\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHAR_PATTERN = /[\x00-\x1f\x7f]/g;
|
||||
const SENTENCE_TERMINATORS = new Set(['.', '!', '?', '。', '!', '?']);
|
||||
|
||||
/**
|
||||
* Reconstructs the composer draft from forwarded keystrokes and reports each
|
||||
* submitted prompt. Input arrives in arbitrary chunks (one keystroke, a paste,
|
||||
* an agent's whole prompt plus Enter), so all state lives across calls.
|
||||
*/
|
||||
export class SubmittedPromptTracker {
|
||||
private buffer = '';
|
||||
private escapeSequence = '';
|
||||
private bufferCodePoints = 0;
|
||||
/** Code points typed past the cap; backspaces eat these before real text. */
|
||||
private overflow = 0;
|
||||
/** Escape sequence in progress; a lone ESC means "just saw ESC". */
|
||||
private sequence = '';
|
||||
private inPaste = false;
|
||||
/** The composer holds text the tracker never saw (history recall); Enter submits nothing. */
|
||||
private tainted = false;
|
||||
|
||||
feed(data: string): string[] {
|
||||
const submitted: string[] = [];
|
||||
|
||||
for (const character of data) {
|
||||
if (this.escapeSequence) {
|
||||
this.escapeSequence += character;
|
||||
// CSI sequences end with a byte in the final-byte range.
|
||||
const isCsiIntroducer = this.escapeSequence === '\x1b[' || this.escapeSequence === '\x1bO';
|
||||
if (/[\x40-\x7e]/.test(character) && !isCsiIntroducer) {
|
||||
const isBracketedPasteMarker = this.escapeSequence === '\x1b[200~' || this.escapeSequence === '\x1b[201~';
|
||||
if (!isBracketedPasteMarker) this.buffer = '';
|
||||
this.escapeSequence = '';
|
||||
}
|
||||
for (const ch of data) {
|
||||
if (this.sequence) {
|
||||
this.continueSequence(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '\x1b') {
|
||||
this.escapeSequence = character;
|
||||
if (ch === '\x1b') {
|
||||
this.sequence = ch;
|
||||
continue;
|
||||
}
|
||||
this.handleKey(ch, submitted);
|
||||
}
|
||||
// A chunk that ENDS in a lone ESC is the Esc key, not the start of a
|
||||
// sequence: xterm hands each key's whole sequence to one write, and the
|
||||
// programmatic senders (an approval deny sends exactly `\x1b`) send it
|
||||
// alone. Leaving it pending would make the next prompt's first character
|
||||
// look like an Alt chord and swallow it.
|
||||
if (this.sequence === '\x1b') this.sequence = '';
|
||||
return submitted;
|
||||
}
|
||||
|
||||
if (character === '\r' || character === '\n') {
|
||||
const prompt = this.buffer.trim();
|
||||
if (prompt) submitted.push(prompt);
|
||||
this.buffer = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '\x08' || character === '\x7f') {
|
||||
this.buffer = Array.from(this.buffer).slice(0, -1).join('');
|
||||
continue;
|
||||
}
|
||||
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
if (codePoint < 0x20 || codePoint === 0x7f) {
|
||||
// Ctrl-C/Ctrl-U and cursor controls make the append-only buffer
|
||||
// unreliable. The next printable text starts a fresh candidate.
|
||||
this.buffer = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
this.buffer += character;
|
||||
if (this.buffer.length > MAX_PROMPT_BUFFER_LENGTH) {
|
||||
this.buffer = this.buffer.slice(-MAX_PROMPT_BUFFER_LENGTH);
|
||||
private continueSequence(ch: string): void {
|
||||
if (this.sequence === '\x1b') {
|
||||
if (ch === '[' || ch === 'O' || ch === ']' || ch === 'P') {
|
||||
this.sequence += ch;
|
||||
return;
|
||||
}
|
||||
this.sequence = ch === '\x1b' ? ch : '';
|
||||
// Alt+Enter inserts a newline in the composer; every other Alt chord
|
||||
// (word movement, Alt+B/F) leaves the text alone.
|
||||
if (ch === '\r' || ch === '\n') this.appendSeparator();
|
||||
return;
|
||||
}
|
||||
|
||||
return submitted;
|
||||
this.sequence += ch;
|
||||
if (this.sequence.length > MAX_ESCAPE_SEQUENCE_LENGTH) {
|
||||
// Not a sequence any terminal sends; what follows is unknowable, so the
|
||||
// draft is tainted rather than titled after the tail of the garbage.
|
||||
this.sequence = '';
|
||||
this.tainted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = this.sequence[1];
|
||||
if (kind === '[') {
|
||||
if (CSI_FINAL_BYTE.test(ch)) {
|
||||
const sequence = this.sequence;
|
||||
this.sequence = '';
|
||||
this.handleCsi(sequence);
|
||||
} else if (!CSI_BODY_BYTE.test(ch)) {
|
||||
// Malformed (an ESC [ followed by text): drop the sequence and let the
|
||||
// character count as typed rather than swallowing up to 64 of them.
|
||||
this.sequence = '';
|
||||
this.handleKeyOrEscape(ch);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (kind === 'O') {
|
||||
// SS3 carries exactly one byte (application-mode cursor keys).
|
||||
this.sequence = '';
|
||||
if (ch === 'A' || ch === 'B') this.tainted = true;
|
||||
return;
|
||||
}
|
||||
// OSC / DCS run to BEL or ST (ESC \).
|
||||
if (ch === '\x07' || this.sequence.endsWith('\x1b\\')) this.sequence = '';
|
||||
}
|
||||
|
||||
private handleKeyOrEscape(ch: string): void {
|
||||
if (ch === '\x1b') {
|
||||
this.sequence = ch;
|
||||
return;
|
||||
}
|
||||
// Only reached mid-chunk from a malformed sequence, where no submission can
|
||||
// be reported; a stray Enter there resets the draft like any other Enter.
|
||||
this.handleKey(ch, []);
|
||||
}
|
||||
|
||||
private handleCsi(sequence: string): void {
|
||||
if (sequence === '\x1b[200~') {
|
||||
this.inPaste = true;
|
||||
return;
|
||||
}
|
||||
if (sequence === '\x1b[201~') {
|
||||
this.inPaste = false;
|
||||
return;
|
||||
}
|
||||
const final = sequence[sequence.length - 1];
|
||||
// Up/Down (with or without modifiers) recall history: the composer now
|
||||
// holds a line this tracker never saw. Everything else leaves the text as
|
||||
// it is: Left/Right/Home/End, Delete (`3~`), Shift+Tab (`Z`), SGR mouse
|
||||
// reports (`<…M`/`m`, forwarded on every wheel tick), focus reports.
|
||||
if (final === 'A' || final === 'B') this.tainted = true;
|
||||
}
|
||||
|
||||
private handleKey(ch: string, submitted: string[]): void {
|
||||
const codePoint = ch.codePointAt(0) ?? 0;
|
||||
if (this.inPaste) {
|
||||
// Pasted newlines are newlines IN the composer, never Enter; they and
|
||||
// the other controls (tabs) become a single separator.
|
||||
if (codePoint < 0x20 || codePoint === 0x7f) this.appendSeparator();
|
||||
else this.append(ch);
|
||||
return;
|
||||
}
|
||||
switch (ch) {
|
||||
case '\r': {
|
||||
const prompt = this.tainted ? '' : this.buffer.trim();
|
||||
if (prompt) submitted.push(prompt);
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
case '\n':
|
||||
// Ctrl+J, and the line feed the send-key route injects for Shift+Enter:
|
||||
// a newline inside the composer, so the lines join with a separator.
|
||||
this.appendSeparator();
|
||||
return;
|
||||
case '\x7f':
|
||||
case '\x08':
|
||||
this.backspace();
|
||||
return;
|
||||
case '\x17': // Ctrl+W: word rubout
|
||||
this.killWord();
|
||||
return;
|
||||
case '\x15': // Ctrl+U: line discard
|
||||
case '\x03': // Ctrl+C: clears the composer (or, empty, arms an exit)
|
||||
this.reset();
|
||||
return;
|
||||
case '\x10': // Ctrl+P
|
||||
case '\x0e': // Ctrl+N
|
||||
case '\x12': // Ctrl+R: history search
|
||||
case '\x1f': // Ctrl+_: undo
|
||||
this.tainted = true;
|
||||
return;
|
||||
default:
|
||||
// Tab (the @-mention completer, which only ever extends the token),
|
||||
// cursor chords (Ctrl+A/E/B/F) and the rest of C0 leave the text alone.
|
||||
if (codePoint < 0x20 || codePoint === 0x7f) return;
|
||||
this.append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
/** One space between lines, never a run of them, and none at the start. */
|
||||
private appendSeparator(): void {
|
||||
if (this.overflow > 0) return;
|
||||
if (!this.buffer || /\s$/.test(this.buffer)) return;
|
||||
this.append(' ');
|
||||
}
|
||||
|
||||
private append(ch: string): void {
|
||||
if (this.bufferCodePoints >= MAX_PROMPT_BUFFER_CODE_POINTS) {
|
||||
this.overflow += 1;
|
||||
return;
|
||||
}
|
||||
this.buffer += ch;
|
||||
this.bufferCodePoints += 1;
|
||||
}
|
||||
|
||||
private backspace(): void {
|
||||
if (this.overflow > 0) {
|
||||
this.overflow -= 1;
|
||||
return;
|
||||
}
|
||||
if (!this.buffer) return;
|
||||
const last = this.buffer.charCodeAt(this.buffer.length - 1);
|
||||
const units = last >= 0xdc00 && last <= 0xdfff && this.buffer.length >= 2 ? 2 : 1;
|
||||
this.buffer = this.buffer.slice(0, -units);
|
||||
this.bufferCodePoints -= 1;
|
||||
}
|
||||
|
||||
private killWord(): void {
|
||||
this.overflow = 0;
|
||||
this.buffer = this.buffer.replace(/\S+\s*$/u, '');
|
||||
this.bufferCodePoints = Array.from(this.buffer).length;
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.buffer = '';
|
||||
this.bufferCodePoints = 0;
|
||||
this.overflow = 0;
|
||||
this.tainted = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a submitted prompt into a compact session title.
|
||||
* Returns null for empty text and slash commands, which are usually controls
|
||||
* such as /clear or /resume rather than the task the user wants to remember.
|
||||
* Turns a submitted prompt into a title, or null when the prompt is not a task:
|
||||
* empty, a slash command (`/clear`, `/model`), or a `!` shell escape.
|
||||
*/
|
||||
export function deriveAutoSessionName(prompt: string): string | null {
|
||||
// Terminal input can legitimately contain ANSI/control bytes; they are
|
||||
// removed before the title is persisted or broadcast.
|
||||
const normalized = prompt
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!normalized || normalized.startsWith('/')) return null;
|
||||
|
||||
const firstSentence = normalized.match(/^.*?(?:[.!?。!?](?:\s|$)|$)/)?.[0]?.trim() || normalized;
|
||||
const codePoints = Array.from(firstSentence);
|
||||
if (codePoints.length <= MAX_AUTO_NAME_CODE_POINTS) return firstSentence;
|
||||
return `${codePoints
|
||||
.slice(0, MAX_AUTO_NAME_CODE_POINTS - 1)
|
||||
.join('')
|
||||
.trimEnd()}…`;
|
||||
const text = prompt.replace(CSI_SEQUENCE_PATTERN, '').replace(CONTROL_CHAR_PATTERN, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (!text || text.startsWith('!') || SLASH_COMMAND_PATTERN.test(text)) return null;
|
||||
return truncateCodePoints(firstSentence(text), MAX_AUTO_NAME_CODE_POINTS);
|
||||
}
|
||||
|
||||
/** Existing Codeman-generated tab names are safe to upgrade on first prompt. */
|
||||
/**
|
||||
* The first sentence, provided it is long enough to be one; a trailing full
|
||||
* stop is dropped because a tab title is not a sentence.
|
||||
*/
|
||||
function firstSentence(text: string): string {
|
||||
const codePoints = Array.from(text);
|
||||
for (let i = MIN_SENTENCE_CODE_POINTS - 1; i < codePoints.length; i++) {
|
||||
if (!SENTENCE_TERMINATORS.has(codePoints[i])) continue;
|
||||
const next = codePoints[i + 1];
|
||||
if (next !== undefined && !/\s/.test(next)) continue;
|
||||
return codePoints
|
||||
.slice(0, i + 1)
|
||||
.join('')
|
||||
.replace(/[.。]+$/, '');
|
||||
}
|
||||
return text.replace(/[.。]+$/, '');
|
||||
}
|
||||
|
||||
/** Cuts to `max` code points with an ellipsis, on a word boundary when one is near the end. */
|
||||
function truncateCodePoints(text: string, max: number): string {
|
||||
const codePoints = Array.from(text);
|
||||
if (codePoints.length <= max) return text;
|
||||
let cut = codePoints.slice(0, max - 1).join('');
|
||||
const lastSpace = cut.lastIndexOf(' ');
|
||||
if (lastSpace >= Math.floor(cut.length / 2)) cut = cut.slice(0, lastSpace);
|
||||
return `${cut.trimEnd()}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name a placeholder becomes: `<prefix>: <title>`, so the tab keeps its
|
||||
* case identity and its `w<n>` counter (the tab strip already renders that
|
||||
* form as the title alone, prefix in the tooltip, and the next-session counter
|
||||
* still matches it). A session with no name at all just takes the title. The
|
||||
* result honours `maxLength` in UTF-16 units, the unit the rename route caps.
|
||||
*/
|
||||
export function composeAutoSessionName(
|
||||
currentName: string,
|
||||
title: string,
|
||||
maxLength = MAX_SESSION_NAME_LENGTH
|
||||
): string {
|
||||
const prefix = currentName.trim();
|
||||
if (!prefix) return fitTitle(title, maxLength);
|
||||
const room = maxLength - prefix.length - 2;
|
||||
if (room <= 0) return prefix;
|
||||
return `${prefix}: ${fitTitle(title, room)}`;
|
||||
}
|
||||
|
||||
/** Fits a title into `maxUnits` UTF-16 units, ellipsis included. */
|
||||
function fitTitle(title: string, maxUnits: number): string {
|
||||
if (title.length <= maxUnits) return title;
|
||||
let units = 0;
|
||||
let keep = 0;
|
||||
for (const codePoint of Array.from(title)) {
|
||||
if (units + codePoint.length > maxUnits - 1) break;
|
||||
units += codePoint.length;
|
||||
keep += 1;
|
||||
}
|
||||
return truncateCodePoints(title, keep + 1);
|
||||
}
|
||||
|
||||
/** Codeman's own `w<n>-<case>` / `s<n>-<case>` placeholders, the only names auto-naming replaces. */
|
||||
export function isGeneratedSessionName(name: string): boolean {
|
||||
return /^[ws]\d+-[a-zA-Z0-9_-]+$/.test(name);
|
||||
}
|
||||
|
||||
+50
-10
@@ -59,6 +59,7 @@ import {
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
type SessionNameSource,
|
||||
type SessionWriteOptions,
|
||||
} from './types.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||
@@ -426,7 +427,14 @@ export class Session extends EventEmitter {
|
||||
|
||||
private _name: string;
|
||||
private _nameSource: SessionNameSource;
|
||||
/**
|
||||
* 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
|
||||
* for a CLI whose Enter runs a command rather than submitting a prompt
|
||||
* (`startMode: 'shell'`), so a shell tab is not renamed after every `ls`.
|
||||
*/
|
||||
private readonly _submittedPromptTracker = new SubmittedPromptTracker();
|
||||
private readonly _acceptsPrompts: boolean;
|
||||
private ptyProcess: pty.IPty | null = null;
|
||||
private _pid: number | null = null;
|
||||
private _status: SessionStatus = 'idle';
|
||||
@@ -658,7 +666,11 @@ export class Session extends EventEmitter {
|
||||
workingDir: string;
|
||||
mode?: SessionMode;
|
||||
name?: string;
|
||||
/** Whether the current name is still eligible for automatic replacement. */
|
||||
/**
|
||||
* Who owns the name (see `SessionNameSource`). Omitted, it is inferred
|
||||
* from the name: Codeman's own `w<n>-<case>` placeholders (or no name)
|
||||
* stay eligible for auto-naming, anything else counts as the user's.
|
||||
*/
|
||||
nameSource?: SessionNameSource;
|
||||
/** Terminal multiplexer instance (tmux) */
|
||||
mux?: TerminalMultiplexer;
|
||||
@@ -730,7 +742,8 @@ export class Session extends EventEmitter {
|
||||
this.mode = config.mode || 'claude';
|
||||
this._name = config.name || '';
|
||||
this._nameSource =
|
||||
config.nameSource ?? (!this._name || isGeneratedSessionName(this._name) ? 'auto' : 'manual');
|
||||
config.nameSource ?? (!this._name || isGeneratedSessionName(this._name) ? 'placeholder' : 'manual');
|
||||
this._acceptsPrompts = getCli(this.mode)?.capabilities.startMode !== 'shell';
|
||||
this._resumeSessionId = config.resumeSessionId;
|
||||
// NOW, not `createdAt`: recovery passes the ORIGINAL creation time of a
|
||||
// days-old tmux session, and seeding last-activity from it would report a
|
||||
@@ -1378,15 +1391,25 @@ export class Session extends EventEmitter {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
/** An explicit rename: the name is the user's from here on and auto-naming never touches it. */
|
||||
set name(value: string) {
|
||||
this._name = value;
|
||||
this._nameSource = 'manual';
|
||||
}
|
||||
|
||||
/** Replace an automatically generated name without taking ownership from auto naming. */
|
||||
/**
|
||||
* Names the tab after its first prompt. Only a placeholder is eligible, and
|
||||
* the session stops being one whether or not the string changed: "first
|
||||
* prompt" means the first, not "every prompt until a rename". Returns
|
||||
* whether the name changed, so the caller knows whether to persist and
|
||||
* broadcast.
|
||||
*/
|
||||
applyAutoName(value: string): boolean {
|
||||
if (this._nameSource !== 'placeholder') return false;
|
||||
const name = value.trim();
|
||||
if (!name || this._nameSource !== 'auto' || this._name === name) return false;
|
||||
if (!name) return false;
|
||||
this._nameSource = 'auto';
|
||||
if (this._name === name) return false;
|
||||
this._name = name;
|
||||
return true;
|
||||
}
|
||||
@@ -3535,8 +3558,8 @@ export class Session extends EventEmitter {
|
||||
* discards the data, but it used to do so with no signal at all — which is how
|
||||
* input could disappear while the caller believed it had been delivered.
|
||||
*/
|
||||
write(data: string): boolean {
|
||||
const submittedPrompt = this._trackSubmit(data);
|
||||
write(data: string, options: SessionWriteOptions = {}): boolean {
|
||||
const submittedPrompt = this._trackSubmit(data, options);
|
||||
if (!this.ptyProcess) return false;
|
||||
this.ptyProcess.write(data);
|
||||
this._emitSubmittedPrompt(submittedPrompt);
|
||||
@@ -3556,14 +3579,31 @@ export class Session extends EventEmitter {
|
||||
return this._lastSubmitAt;
|
||||
}
|
||||
|
||||
private _trackSubmit(data: string): string[] {
|
||||
const submitted = this._submittedPromptTracker.feed(data);
|
||||
/**
|
||||
* Stamps the pane's last Enter for EVERY write, and feeds the auto-name
|
||||
* tracker only for user-originated input on a prompt-taking CLI. Ralph
|
||||
* kick-starts, respawn `/clear`s, cron launches, approval answers and the
|
||||
* trust-dialog keys all arrive without `fromUser` and so can never name a tab.
|
||||
*/
|
||||
private _trackSubmit(data: string, options: SessionWriteOptions): string[] {
|
||||
const submitted = options.fromUser && this._acceptsPrompts ? this._submittedPromptTracker.feed(data) : [];
|
||||
if (data.includes('\r') || data.includes('\n')) {
|
||||
this._lastSubmitAt = Date.now();
|
||||
}
|
||||
return submitted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds user input that reaches the pane AROUND the write paths: the
|
||||
* send-key route injects Shift+Enter's line feed through `tmux send-keys -H`
|
||||
* directly, and without this the two lines of a prompt joined with no
|
||||
* separator. Reports submissions like a write would (a line feed never is one).
|
||||
*/
|
||||
trackUserInput(data: string): void {
|
||||
if (!this._acceptsPrompts) return;
|
||||
this._emitSubmittedPrompt(this._submittedPromptTracker.feed(data));
|
||||
}
|
||||
|
||||
private _emitSubmittedPrompt(prompts: string[]): void {
|
||||
for (const prompt of prompts) {
|
||||
this.emit('promptSubmitted', prompt);
|
||||
@@ -3664,8 +3704,8 @@ export class Session extends EventEmitter {
|
||||
* session.writeViaMux('/init\r'); // Send /init command
|
||||
* ```
|
||||
*/
|
||||
async writeViaMux(data: string): Promise<boolean> {
|
||||
const submittedPrompt = this._trackSubmit(data);
|
||||
async writeViaMux(data: string, options: SessionWriteOptions = {}): Promise<boolean> {
|
||||
const submittedPrompt = this._trackSubmit(data, options);
|
||||
if (this._mux && this._muxSession) {
|
||||
const sent = await this._mux.sendInput(this.id, data);
|
||||
if (sent) this._emitSubmittedPrompt(submittedPrompt);
|
||||
|
||||
+18
-3
@@ -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;
|
||||
|
||||
@@ -252,6 +252,7 @@
|
||||
'Ultracode Agents': 'Ultracode 智能体',
|
||||
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
|
||||
'Approvals Inbox': '审批收件箱',
|
||||
'Auto-name Sessions': '自动命名会话',
|
||||
Approvals: '审批',
|
||||
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
|
||||
'No pending approvals': '没有待处理的审批',
|
||||
|
||||
@@ -2047,6 +2047,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsAutoNameSessionsItem" data-search="auto name session title first prompt tab rename">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Auto-name Sessions <span class="set-tag">synced</span></span>
|
||||
<span class="set-row-desc">Title a new tab after its first prompt, keeping the case prefix. Renamed tabs are never touched.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoNameSessions"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsMobileOverviewItem" data-search="overview home screen phone logo">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Overview Home Screen <span class="set-tag">phone</span></span>
|
||||
|
||||
@@ -408,6 +408,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// header), so the row is hidden elsewhere rather than offering a toggle that
|
||||
// changes nothing. Default ON — only an explicit false turns it off.
|
||||
document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
|
||||
// Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables).
|
||||
document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true;
|
||||
const lineageItem = document.getElementById('appSettingsLineageLinesItem');
|
||||
if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
|
||||
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
|
||||
@@ -2111,6 +2113,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||
sessionLineageLines: document.getElementById('appSettingsLineageLines').checked,
|
||||
autoNameSessions: document.getElementById('appSettingsAutoNameSessions').checked,
|
||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||
|
||||
@@ -1081,7 +1081,6 @@ export function registerSessionRoutes(
|
||||
workingDir,
|
||||
mode,
|
||||
name: body.name || '',
|
||||
nameSource: body.name ? undefined : 'auto',
|
||||
mux: ctx.mux,
|
||||
useMux: true,
|
||||
niceConfig: globalNice,
|
||||
@@ -1596,6 +1595,9 @@ export function registerSessionRoutes(
|
||||
|
||||
// Write input to PTY. Direct write is synchronous; writeViaMux
|
||||
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
|
||||
// Every write here is `fromUser`: this route carries a person's prompt, or an
|
||||
// agent's on their behalf, so it may name the tab (Ralph, respawn, cron and
|
||||
// approvals write through the session directly and never say so).
|
||||
//
|
||||
// Because the response has already been sent by then, a failure there is the
|
||||
// one case the caller can never learn about — so the dedup bookkeeping is
|
||||
@@ -1618,32 +1620,32 @@ export function registerSessionRoutes(
|
||||
} else if (useMux && waitPromise) {
|
||||
// The response is already staying open for the wait, so the tmux write can be
|
||||
// awaited here. This is the ONE path where a writeViaMux failure is observable.
|
||||
const ok = await session.writeViaMux(inputStr).catch(() => false);
|
||||
const ok = await session.writeViaMux(inputStr, { fromUser: true }).catch(() => false);
|
||||
if (ok) {
|
||||
delivered = true;
|
||||
} else {
|
||||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||||
delivered = session.write(inputStr);
|
||||
delivered = session.write(inputStr, { fromUser: true });
|
||||
if (!delivered) undoOnFailure();
|
||||
}
|
||||
} else if (useMux) {
|
||||
// Fire-and-forget: don't block the HTTP response on a tmux child process.
|
||||
// Fallback to a direct write on failure. Unchanged from before send-and-wait.
|
||||
session
|
||||
.writeViaMux(inputStr)
|
||||
.writeViaMux(inputStr, { fromUser: true })
|
||||
.then((ok) => {
|
||||
if (ok) return;
|
||||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||||
if (!session.write(inputStr)) undoOnFailure();
|
||||
if (!session.write(inputStr, { fromUser: true })) undoOnFailure();
|
||||
})
|
||||
.catch(() => {
|
||||
if (!session.write(inputStr)) undoOnFailure();
|
||||
if (!session.write(inputStr, { fromUser: true })) undoOnFailure();
|
||||
});
|
||||
} else {
|
||||
// Same rollback. NOT an error response, deliberately: a session can
|
||||
// legitimately have no PTY yet (created but not started), and callers have
|
||||
// always been able to write to one without a 4xx.
|
||||
delivered = session.write(inputStr);
|
||||
delivered = session.write(inputStr, { fromUser: true });
|
||||
if (!delivered && tagged) {
|
||||
session.forgetInputSeq(clientId as string, seq as number);
|
||||
}
|
||||
@@ -1892,6 +1894,9 @@ export function registerSessionRoutes(
|
||||
console.error('[Server] send-key failed:', err);
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
|
||||
}
|
||||
// The bytes bypassed the session's write path, so tell the auto-name
|
||||
// tracker about them or the two lines of a prompt join with no separator.
|
||||
session.trackUserInput(hex.map((byte) => String.fromCharCode(parseInt(byte, 16))).join(''));
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -3485,7 +3490,6 @@ export function registerSessionRoutes(
|
||||
const session = new Session({
|
||||
workingDir: resolvedCasePath,
|
||||
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
|
||||
nameSource: sessionName ? undefined : 'auto',
|
||||
mux: ctx.mux,
|
||||
useMux: true,
|
||||
mode: mode,
|
||||
|
||||
@@ -185,7 +185,8 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
// Typed input from a claim-holding desktop keeps the claim "hot"
|
||||
// and re-asserts the desktop layout after a mobile override.
|
||||
if (holdsDesktopClaim) session.noteDesktopActivity();
|
||||
delivered = session.write(msg.d);
|
||||
// Browser keystrokes are the user's own, so they may name the tab.
|
||||
delivered = session.write(msg.d, { fromUser: true });
|
||||
// A session whose PTY is gone swallows the write. ACKing anyway told
|
||||
// the client to drop the frame from its durable queue and left the seq
|
||||
// burnt, so the retry that reliable delivery exists for was rejected as
|
||||
|
||||
@@ -1230,6 +1230,13 @@ export const SettingsUpdateSchema = z
|
||||
* already pending immediately.
|
||||
*/
|
||||
approvalsInboxEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Auto-name sessions: a placeholder tab (`w3-case`) takes its first real
|
||||
* prompt as a title (`w3-case: fix the login redirect`). Synced, default
|
||||
* OFF: the prompt lands in mux-sessions.json, every session:updated
|
||||
* broadcast and /api/search, which is the user's choice to make.
|
||||
*/
|
||||
autoNameSessions: z.boolean().optional(),
|
||||
/**
|
||||
* Read My Mind (docs/readmymind-plan.md): capture the user's submitted
|
||||
* prompts into per-case intent profiles. SYNCED, default OFF (opt-in:
|
||||
|
||||
@@ -1729,6 +1729,9 @@ export class WebServer extends EventEmitter {
|
||||
registerAttachment: (id: string, filePath: string, source: 'external' | 'codex-generated') =>
|
||||
this.registerAttachment(id, filePath, source),
|
||||
updateSessionName: (id: string, name: string) => this.mux.updateSessionName(id, name),
|
||||
// Opt-in: the first prompt lands in the tab name, mux-sessions.json, every
|
||||
// session:updated broadcast and /api/search, so it is a choice, not a default.
|
||||
isAutoNameEnabled: async () => (await this.readSettings()).autoNameSessions === true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||
import { fileStreamManager } from '../file-stream-manager.js';
|
||||
import { sessionWaits } from './session-wait-registry.js';
|
||||
import { approvalInbox } from './approval-inbox.js';
|
||||
import { deriveAutoSessionName } from '../session-auto-name.js';
|
||||
import { composeAutoSessionName, deriveAutoSessionName } from '../session-auto-name.js';
|
||||
import { MAX_SESSION_NAME_LENGTH } from '../config/terminal-limits.js';
|
||||
|
||||
/** Stored listener references for session cleanup (prevents memory leaks) */
|
||||
export interface SessionListenerRefs {
|
||||
@@ -86,6 +87,8 @@ interface SessionListenerDeps {
|
||||
getStore(): import('../state-store.js').StateStore;
|
||||
registerAttachment(sessionId: string, filePath: string, source: 'external' | 'codex-generated'): Promise<void>;
|
||||
updateSessionName(sessionId: string, name: string): boolean;
|
||||
/** The synced `autoNameSessions` setting, read fresh so a flip applies to the next prompt. */
|
||||
isAutoNameEnabled(): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -455,13 +458,28 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
});
|
||||
},
|
||||
|
||||
/** Assigns a bounded local title from the first real task prompt. */
|
||||
/**
|
||||
* Names a placeholder tab after its first real prompt (`w3-case: fix the
|
||||
* login redirect`), behind the synced `autoNameSessions` setting. The
|
||||
* eligibility check comes first so the settings read costs nothing on the
|
||||
* prompts of an already-named session; a prompt that yields no title (a
|
||||
* slash command) leaves the session eligible for the next one.
|
||||
*/
|
||||
promptSubmitted: (prompt: string) => {
|
||||
const name = deriveAutoSessionName(prompt);
|
||||
if (!name || !session.applyAutoName(name)) return;
|
||||
deps.updateSessionName(session.id, session.name);
|
||||
deps.persistSessionState(session);
|
||||
deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
|
||||
if (session.nameSource !== 'placeholder') return;
|
||||
const title = deriveAutoSessionName(prompt);
|
||||
if (!title) return;
|
||||
void deps
|
||||
.isAutoNameEnabled()
|
||||
.then((enabled) => {
|
||||
if (!enabled) return;
|
||||
const name = composeAutoSessionName(session.name, title, MAX_SESSION_NAME_LENGTH);
|
||||
if (!session.applyAutoName(name)) return;
|
||||
deps.updateSessionName(session.id, session.name);
|
||||
deps.persistSessionState(session);
|
||||
deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
|
||||
})
|
||||
.catch((err) => console.error(`[Session] auto-name failed for ${session.id}:`, err));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user