Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/wiki/The-Dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
101 changes: 101 additions & 0 deletions src/session-auto-name.ts
Original file line number Diff line number Diff line change
@@ -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);
}
44 changes: 39 additions & 5 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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);
}
}

/**
Expand Down Expand Up @@ -3299,13 +3330,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;
Expand Down
5 changes: 5 additions & 0 deletions src/types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 */
Expand Down
2 changes: 2 additions & 0 deletions src/web/routes/session-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,7 @@ export function registerSessionRoutes(
workingDir,
mode,
name: body.name || '',
nameSource: body.name ? undefined : 'auto',
mux: ctx.mux,
useMux: true,
niceConfig: globalNice,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}

Expand Down Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions src/web/session-listener-wiring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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. */
Expand All @@ -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 {
Expand Down Expand Up @@ -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));
},
};
}

Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -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 changes: 27 additions & 0 deletions test/session-listener-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading