From 268e4819ff21dce20064c307ac9b7c5dacfa955d Mon Sep 17 00:00:00 2001 From: codeman-local Date: Thu, 3 Sep 2026 18:14:29 +0800 Subject: [PATCH] feat: auto-name sessions from first prompt --- docs/wiki/The-Dashboard.md | 8 +++ src/session-auto-name.ts | 101 +++++++++++++++++++++++++++ src/session.ts | 44 ++++++++++-- src/types/session.ts | 5 ++ src/web/routes/session-routes.ts | 2 + src/web/server.ts | 2 + src/web/session-listener-wiring.ts | 18 ++++- test/session-listener-wiring.test.ts | 27 +++++++ test/session-submit-anchor.test.ts | 29 ++++++++ 9 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 src/session-auto-name.ts diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index d51308690..f6c2d1710 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -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. diff --git a/src/session-auto-name.ts b/src/session-auto-name.ts new file mode 100644 index 000000000..82c57890f --- /dev/null +++ b/src/session-auto-name.ts @@ -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); +} diff --git a/src/session.ts b/src/session.ts index 750690eac..68a493c6d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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 */ @@ -56,6 +56,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'; @@ -116,6 +117,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, @@ -406,6 +408,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'; @@ -610,6 +614,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 */ @@ -677,6 +683,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 @@ -1212,6 +1220,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 { @@ -1355,6 +1376,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, @@ -3204,9 +3226,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; } @@ -3223,10 +3246,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); + } } /** @@ -3299,13 +3330,16 @@ export class Session extends EventEmitter { * ``` */ async writeViaMux(data: string): Promise { - 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; diff --git a/src/types/session.ts b/src/types/session.ts index 3c34b49d4..d32ef0ce5 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -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' @@ -551,6 +554,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 */ diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 845849905..6b8d0aa75 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -1103,6 +1103,7 @@ export function registerSessionRoutes( workingDir, mode, name: body.name || '', + nameSource: body.name ? undefined : 'auto', mux: ctx.mux, useMux: true, niceConfig: globalNice, @@ -3333,6 +3334,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, diff --git a/src/web/server.ts b/src/web/server.ts index 0cf318d65..08794f1c1 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1620,6 +1620,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), }; } @@ -2768,6 +2769,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 diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts index 33724557f..2af8d7a07 100644 --- a/src/web/session-listener-wiring.ts +++ b/src/web/session-listener-wiring.ts @@ -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; + 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); } diff --git a/test/session-listener-wiring.test.ts b/test/session-listener-wiring.test.ts index 74ea312c3..5ffd56e06 100644 --- a/test/session-listener-wiring.test.ts +++ b/test/session-listener-wiring.test.ts @@ -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[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); + }); }); diff --git a/test/session-submit-anchor.test.ts b/test/session-submit-anchor.test.ts index a1298fb9a..5c75564c9 100644 --- a/test/session-submit-anchor.test.ts +++ b/test/session-submit-anchor.test.ts @@ -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('我的工作窗口'); + }); +});