Merge pull request #376 from shenlvkang-collab/feat/auto-session-names-upstream

This commit is contained in:
Codeman maintainer
2026-09-15 17:19:57 +02:00
9 changed files with 229 additions and 7 deletions
+101
View File
@@ -0,0 +1,101 @@
/**
* Helpers for assigning a useful default name after the first submitted 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.
*/
const MAX_PROMPT_BUFFER_LENGTH = 8_192;
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.
*/
export class SubmittedPromptTracker {
private buffer = '';
private escapeSequence = '';
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 = '';
}
continue;
}
if (character === '\x1b') {
this.escapeSequence = character;
continue;
}
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);
}
}
return submitted;
}
}
/**
* 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.
*/
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()}…`;
}
/** Existing Codeman-generated tab names are safe to upgrade on first prompt. */
export function isGeneratedSessionName(name: string): boolean {
return /^[ws]\d+-[a-zA-Z0-9_-]+$/.test(name);
}
+39 -5
View File
@@ -23,7 +23,7 @@
* ralph-tracker (todo/completion parsing), bash-tool-parser (tool invocation tracking),
* task-tracker (background tasks), mux-interface (tmux abstraction)
* @consumedby session-manager, web/server, respawn-controller
* @emits session:terminal, session:idle, session:working, session:completion, session:exit
* @emits session:terminal, session:idle, session:working, session:completion, session:promptSubmitted, session:exit
*
* @module session
*/
@@ -58,6 +58,7 @@ import {
type OmpConfig,
type SessionRemote,
type SessionDocker,
type SessionNameSource,
} from './types.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { probeDockerCliVersion } from './docker-hosts.js';
@@ -121,6 +122,7 @@ import { SessionAutoOps } from './session-auto-ops.js';
import { detectUsageLimitPause } from './usage-limit-patterns.js';
import { SessionTaskCache } from './session-task-cache.js';
import { InteractivePtyExitBreaker } from './session-pty-exit-breaker.js';
import { isGeneratedSessionName, SubmittedPromptTracker } from './session-auto-name.js';
import { parseTerminalAttachmentRequests } from './attachment-magic.js';
import {
sanitizeAttachmentHistory,
@@ -423,6 +425,8 @@ export class Session extends EventEmitter {
private _taskCache = new SessionTaskCache();
private _name: string;
private _nameSource: SessionNameSource;
private readonly _submittedPromptTracker = new SubmittedPromptTracker();
private ptyProcess: pty.IPty | null = null;
private _pid: number | null = null;
private _status: SessionStatus = 'idle';
@@ -654,6 +658,8 @@ export class Session extends EventEmitter {
workingDir: string;
mode?: SessionMode;
name?: string;
/** Whether the current name is still eligible for automatic replacement. */
nameSource?: SessionNameSource;
/** Terminal multiplexer instance (tmux) */
mux?: TerminalMultiplexer;
/** Whether to use multiplexer wrapping */
@@ -723,6 +729,8 @@ export class Session extends EventEmitter {
this.createdAt = config.createdAt || Date.now();
this.mode = config.mode || 'claude';
this._name = config.name || '';
this._nameSource =
config.nameSource ?? (!this._name || isGeneratedSessionName(this._name) ? 'auto' : 'manual');
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
@@ -1372,6 +1380,19 @@ export class Session extends EventEmitter {
set name(value: string) {
this._name = value;
this._nameSource = 'manual';
}
/** Replace an automatically generated name without taking ownership from auto naming. */
applyAutoName(value: string): boolean {
const name = value.trim();
if (!name || this._nameSource !== 'auto' || this._name === name) return false;
this._name = name;
return true;
}
get nameSource(): SessionNameSource {
return this._nameSource;
}
setAutoClear(enabled: boolean, threshold?: number): void {
@@ -1515,6 +1536,7 @@ export class Session extends EventEmitter {
// attach repaint, so the home screens' quiet ordering survives a restart.
lastActivityAt: this._wireActivityAt,
name: this._name,
nameSource: this._nameSource,
mode: this.mode,
autoClearEnabled: this._autoOps.autoClearEnabled,
autoClearThreshold: this._autoOps.autoClearThreshold,
@@ -3514,9 +3536,10 @@ export class Session extends EventEmitter {
* input could disappear while the caller believed it had been delivered.
*/
write(data: string): boolean {
this._trackSubmit(data);
const submittedPrompt = this._trackSubmit(data);
if (!this.ptyProcess) return false;
this.ptyProcess.write(data);
this._emitSubmittedPrompt(submittedPrompt);
return true;
}
@@ -3533,10 +3556,18 @@ export class Session extends EventEmitter {
return this._lastSubmitAt;
}
private _trackSubmit(data: string): void {
private _trackSubmit(data: string): string[] {
const submitted = this._submittedPromptTracker.feed(data);
if (data.includes('\r') || data.includes('\n')) {
this._lastSubmitAt = Date.now();
}
return submitted;
}
private _emitSubmittedPrompt(prompts: string[]): void {
for (const prompt of prompts) {
this.emit('promptSubmitted', prompt);
}
}
/**
@@ -3634,13 +3665,16 @@ export class Session extends EventEmitter {
* ```
*/
async writeViaMux(data: string): Promise<boolean> {
this._trackSubmit(data);
const submittedPrompt = this._trackSubmit(data);
if (this._mux && this._muxSession) {
return this._mux.sendInput(this.id, data);
const sent = await this._mux.sendInput(this.id, data);
if (sent) this._emitSubmittedPrompt(submittedPrompt);
return sent;
}
// Fallback to PTY write
if (this.ptyProcess) {
this.ptyProcess.write(data);
this._emitSubmittedPrompt(submittedPrompt);
return true;
}
return false;
+5
View File
@@ -58,6 +58,9 @@ export type SessionMode =
| 'deepseek'
| 'omp';
/** Whether a session name may still be replaced by the first submitted prompt. */
export type SessionNameSource = 'auto' | 'manual';
export type RemoteCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
@@ -614,6 +617,8 @@ export interface SessionState {
lastActivityAt: number;
/** Session display name */
name?: string;
/** Name ownership; auto names are replaced after the first real prompt. */
nameSource?: SessionNameSource;
/** Session mode */
mode?: SessionMode;
/** Auto-clear enabled */
+2
View File
@@ -1081,6 +1081,7 @@ export function registerSessionRoutes(
workingDir,
mode,
name: body.name || '',
nameSource: body.name ? undefined : 'auto',
mux: ctx.mux,
useMux: true,
niceConfig: globalNice,
@@ -3484,6 +3485,7 @@ 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,
+2
View File
@@ -1728,6 +1728,7 @@ export class WebServer extends EventEmitter {
getStore: () => this.store,
registerAttachment: (id: string, filePath: string, source: 'external' | 'codex-generated') =>
this.registerAttachment(id, filePath, source),
updateSessionName: (id: string, name: string) => this.mux.updateSessionName(id, name),
};
}
@@ -2903,6 +2904,7 @@ export class WebServer extends EventEmitter {
workingDir: muxSession.workingDir,
mode: muxSession.mode,
name: sessionName,
nameSource: savedState?.nameSource,
// When the session FIRST started, not when this server booted.
// Without it every recovered session was restamped `Date.now()` on
// each restart, so a week-old pane read as "created 2m ago" on the
+16 -2
View File
@@ -3,7 +3,7 @@
*
* Extracted from server.ts for modularity. Provides:
* - `SessionListenerRefs` interface (named listener references for leak-free cleanup)
* - `createSessionListeners()` — builds all 25 listener handlers via dependency injection
* - `createSessionListeners()` — builds all session listener handlers via dependency injection
* - `attachSessionListeners()` / `detachSessionListeners()` — symmetric attach/detach
*
* The detach function deduplicates a pattern that was previously copy-pasted 3 times
@@ -29,6 +29,7 @@ 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';
/** Stored listener references for session cleanup (prevents memory leaks) */
export interface SessionListenerRefs {
@@ -63,6 +64,7 @@ export interface SessionListenerRefs {
bashToolEnd: (tool: ActiveBashTool) => void;
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
attachmentRequested: (event: { path: string; source: 'external' | 'codex-generated' }) => void;
promptSubmitted: (prompt: string) => void;
}
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
@@ -83,10 +85,11 @@ interface SessionListenerDeps {
cleanupRespawnOnExit(sessionId: string): void;
getStore(): import('../state-store.js').StateStore;
registerAttachment(sessionId: string, filePath: string, source: 'external' | 'codex-generated'): Promise<void>;
updateSessionName(sessionId: string, name: string): boolean;
}
/**
* Creates all 26 session listener handlers, capturing dependencies via closure.
* Creates all session listener handlers, capturing dependencies via closure.
* Call `attachSessionListeners()` after to wire them to the session.
*/
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
@@ -451,6 +454,15 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
console.error(`[Attachment] Failed to register ${event.path} for ${session.id}:`, err);
});
},
/** Assigns a bounded local title from the first real task prompt. */
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));
},
};
}
@@ -487,6 +499,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
session.on('bashToolEnd', refs.bashToolEnd);
session.on('bashToolsUpdate', refs.bashToolsUpdate);
session.on('attachmentRequested', refs.attachmentRequested);
session.on('promptSubmitted', refs.promptSubmitted);
}
/** Detach all listeners from a session (prevents memory leaks from closure references). */
@@ -522,4 +535,5 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
session.off('bashToolEnd', refs.bashToolEnd);
session.off('bashToolsUpdate', refs.bashToolsUpdate);
session.off('attachmentRequested', refs.attachmentRequested);
session.off('promptSubmitted', refs.promptSubmitted);
}