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
+8
View File
@@ -66,6 +66,14 @@ reloading while a permission prompt is blocking does not lose the red tab.
Tabs can also be dragged to reorder.
### Automatic session names
New sessions start with a short project/sequence name so they can be created immediately.
After the first task prompt is submitted, Codeman replaces that placeholder with a short
title derived locally from the prompt's first sentence. Slash commands such as `/clear` do
not become titles. A name you set with the inline rename action is treated as manual and is
never overwritten by automatic naming.
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
+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);
}
+27
View File
@@ -20,4 +20,31 @@ describe('session listener wiring', () => {
);
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
});
it('renames an eligible session when its first prompt is submitted', () => {
const session = new Session({ id: 'wiring-auto-name-test', workingDir: '/tmp', name: 'w1-demo' });
const updateSessionName = vi.fn(() => true);
const persistSessionState = vi.fn();
const broadcast = vi.fn();
const getSessionStateWithRespawn = vi.fn(() => session.toState());
const deps = {
updateSessionName,
persistSessionState,
broadcast,
getSessionStateWithRespawn,
} as unknown as Parameters<typeof createSessionListeners>[1];
const refs = createSessionListeners(session, deps);
refs.promptSubmitted('整理登录模块并补充测试');
expect(session.name).toBe('整理登录模块并补充测试');
expect(updateSessionName).toHaveBeenCalledWith('wiring-auto-name-test', '整理登录模块并补充测试');
expect(persistSessionState).toHaveBeenCalledWith(session);
expect(broadcast).toHaveBeenCalled();
session.name = '人工命名';
refs.promptSubmitted('新的任务不能覆盖人工命名');
expect(session.name).toBe('人工命名');
expect(updateSessionName).toHaveBeenCalledTimes(1);
});
});
+29
View File
@@ -12,6 +12,7 @@
import { describe, it, expect } from 'vitest';
import { Session } from '../src/session.js';
import { deriveAutoSessionName, SubmittedPromptTracker } from '../src/session-auto-name.js';
describe('session submit anchor', () => {
it('records the pane Enter and carries it into persisted state', () => {
@@ -53,3 +54,31 @@ describe('session submit anchor', () => {
expect(recovered.lastSubmitAt).toBe(0);
});
});
describe('automatic session names', () => {
it('builds a bounded title from the first sentence without exposing controls', () => {
expect(deriveAutoSessionName(' 修复登录跳转问题。\n不要改数据库')).toBe('修复登录跳转问题。');
expect(deriveAutoSessionName('/clear')).toBeNull();
expect(deriveAutoSessionName('\x1b[31m整理项目文档\x1b[0m')).toBe('整理项目文档');
expect(Array.from(deriveAutoSessionName('a'.repeat(200)) ?? '')).toHaveLength(72);
});
it('tracks chunked typing, backspace, and Enter without treating arrows as prompt text', () => {
const tracker = new SubmittedPromptTracker();
expect(tracker.feed('修复登')).toEqual([]);
expect(tracker.feed('录跳转\x7f问题\r')).toEqual(['修复登录跳问题']);
expect(tracker.feed('旧内容\x1b[A新内容\r')).toEqual(['新内容']);
});
it('keeps manual names protected while generated names remain eligible', () => {
const generated = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(generated.nameSource).toBe('auto');
expect(generated.applyAutoName('修复登录')).toBe(true);
expect(generated.applyAutoName('继续重命名')).toBe(true);
const manual = new Session({ workingDir: '/tmp', name: '我的工作窗口' });
expect(manual.nameSource).toBe('manual');
expect(manual.applyAutoName('不应覆盖')).toBe(false);
expect(manual.name).toBe('我的工作窗口');
});
});