diff --git a/server/coding-cli/providers/claude.ts b/server/coding-cli/providers/claude.ts index fbae60d4..3905295a 100644 --- a/server/coding-cli/providers/claude.ts +++ b/server/coding-cli/providers/claude.ts @@ -326,6 +326,8 @@ export function parseSessionContent(content: string, options: ParseSessionOption let createdAt: number | undefined let lastActivityAt: number | undefined let title: string | undefined + let customTitle: string | undefined + let agentName: string | undefined let summary: string | undefined let firstUserMessage: string | undefined let gitBranch: string | undefined @@ -408,6 +410,13 @@ export function parseSessionContent(content: string, options: ParseSessionOption } } + if (obj?.type === 'custom-title' && typeof obj?.customTitle === 'string' && obj.customTitle.trim()) { + customTitle = obj.customTitle.trim().slice(0, 200) + } + if (obj?.type === 'agent-name' && typeof obj?.agentName === 'string' && obj.agentName.trim()) { + agentName = obj.agentName.trim().slice(0, 200) + } + if (!firstUserMessage) { if (typeof userMessageText === 'string') { const normalized = normalizeFirstUserMessage(userMessageText) @@ -496,7 +505,7 @@ export function parseSessionContent(content: string, options: ParseSessionOption cwd, createdAt, lastActivityAt, - title, + title: customTitle ?? agentName ?? title, summary, firstUserMessage, messageCount: lines.length, diff --git a/server/index.ts b/server/index.ts index eaeb37e7..d444d616 100644 --- a/server/index.ts +++ b/server/index.ts @@ -48,6 +48,7 @@ import { parseTrustProxyEnv } from './request-ip.js' import { createTabsRegistryStore } from './tabs-registry/store.js' import { checkForUpdate, createCachedUpdateChecker } from './updater/version-checker.js' import { SessionAssociationCoordinator } from './session-association-coordinator.js' +import { collectAppliedSessionAssociations } from './session-association-updates.js' import { loadOrCreateServerInstanceId } from './instance-id.js' import { createSettingsRouter } from './settings-router.js' import { createPerfRouter } from './perf-router.js' @@ -508,33 +509,27 @@ async function main() { sessionsSync.publish(projects) const associationMetaUpserts: ReturnType = [] const pendingMetadataSync = new Map() - const nonClaudeProjects = projects.map((project) => ({ - ...project, - sessions: project.sessions.filter((session) => session.provider !== 'claude'), - })) - for (const session of associationCoordinator.collectNewOrAdvanced(nonClaudeProjects)) { - const result = associationCoordinator.associateSingleSession(session) - if (!result.associated || !result.terminalId) continue + for (const { session, terminalId } of collectAppliedSessionAssociations(associationCoordinator, projects)) { log.info({ event: 'session_bind_applied', - terminalId: result.terminalId, + terminalId, sessionId: session.sessionId, provider: session.provider, }, 'session_bind_applied') try { wsHandler.broadcast({ type: 'terminal.session.associated' as const, - terminalId: result.terminalId, + terminalId, sessionId: session.sessionId, }) const metaUpsert = terminalMetadata.associateSession( - result.terminalId, + terminalId, session.provider, session.sessionId, ) if (metaUpsert) associationMetaUpserts.push(metaUpsert) } catch (err) { - log.warn({ err, terminalId: result.terminalId }, 'Failed to broadcast session association') + log.warn({ err, terminalId }, 'Failed to broadcast session association') } } @@ -577,10 +572,9 @@ async function main() { } }) - // One-time session association for newly discovered Claude sessions. - // When the indexer first discovers a session file, associate it with the oldest - // unassociated claude-mode terminal matching the session's cwd. This allows the - // terminal to resume the session after server restart. + // Fast-path session association for newly discovered Claude sessions. + // Most providers now associate from onUpdate, but onNewSession still reduces the + // delay before a freshly discovered Claude session binds to a matching terminal. // // Broadcast message type: { type: 'terminal.session.associated', terminalId: string, sessionId: string } codingCliIndexer.onNewSession((session) => { diff --git a/server/session-association-updates.ts b/server/session-association-updates.ts new file mode 100644 index 00000000..931bb6c0 --- /dev/null +++ b/server/session-association-updates.ts @@ -0,0 +1,27 @@ +import type { ProjectGroup, CodingCliSession } from './coding-cli/types.js' +import type { SessionAssociationCoordinator } from './session-association-coordinator.js' + +export type AppliedSessionAssociation = { + session: CodingCliSession + terminalId: string +} + +type SessionAssociationCoordinatorLike = Pick + +export function collectAppliedSessionAssociations( + coordinator: SessionAssociationCoordinatorLike, + projects: ProjectGroup[], +): AppliedSessionAssociation[] { + const applied: AppliedSessionAssociation[] = [] + + for (const session of coordinator.collectNewOrAdvanced(projects)) { + const result = coordinator.associateSingleSession(session) + if (!result.associated || !result.terminalId) continue + applied.push({ + session, + terminalId: result.terminalId, + }) + } + + return applied +} diff --git a/test/server/session-association.test.ts b/test/server/session-association.test.ts index 6bb3d799..a6ee7070 100644 --- a/test/server/session-association.test.ts +++ b/test/server/session-association.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, vi } from 'vitest' -import { TerminalRegistry, modeSupportsResume } from '../../server/terminal-registry' +import { TerminalRegistry } from '../../server/terminal-registry' import { CodingCliSessionIndexer } from '../../server/coding-cli/session-indexer' -import { makeSessionKey, type CodingCliSession } from '../../server/coding-cli/types' +import { makeSessionKey, type CodingCliSession, type ProjectGroup } from '../../server/coding-cli/types' import { SessionAssociationCoordinator } from '../../server/session-association-coordinator' import { TerminalMetadataService } from '../../server/terminal-metadata-service' +import { collectAppliedSessionAssociations } from '../../server/session-association-updates' vi.mock('node-pty', () => ({ spawn: vi.fn(() => ({ @@ -698,28 +699,16 @@ describe('Codex Session-Terminal Association via onUpdate', () => { function associateOnUpdate( registry: TerminalRegistry, - projects: { projectPath: string; sessions: CodingCliSession[] }[], + projects: ProjectGroup[], broadcasts: any[], ) { - for (const project of projects) { - for (const session of project.sessions) { - if (!modeSupportsResume(session.provider)) continue - if (!session.cwd) continue - const unassociated = registry.findUnassociatedTerminals(session.provider, session.cwd) - if (unassociated.length === 0) continue - - const term = unassociated[0] - if (session.lastActivityAt < term.createdAt - ASSOCIATION_MAX_AGE_MS) continue - - const associated = registry.setResumeSessionId(term.terminalId, session.sessionId) - if (!associated) continue - - broadcasts.push({ - type: 'terminal.session.associated', - terminalId: term.terminalId, - sessionId: session.sessionId, - }) - } + const coordinator = new SessionAssociationCoordinator(registry, ASSOCIATION_MAX_AGE_MS) + for (const { session, terminalId } of collectAppliedSessionAssociations(coordinator, projects)) { + broadcasts.push({ + type: 'terminal.session.associated', + terminalId, + sessionId: session.sessionId, + }) } } diff --git a/test/unit/server/coding-cli/claude-provider.test.ts b/test/unit/server/coding-cli/claude-provider.test.ts index 066c81d7..b5a95389 100644 --- a/test/unit/server/coding-cli/claude-provider.test.ts +++ b/test/unit/server/coding-cli/claude-provider.test.ts @@ -115,6 +115,45 @@ describe('parseSessionContent() - token usage snapshots', () => { expect(meta.lastActivityAt).toBe(Date.parse('2026-03-01T00:00:04.000Z')) }) + it('prefers a renamed custom title over agent and prompt-derived titles', () => { + const meta = parseSessionContent([ + JSON.stringify({ + type: 'user', + message: { role: 'user', content: 'Original prompt title' }, + timestamp: '2026-03-01T00:00:03.000Z', + }), + JSON.stringify({ + type: 'custom-title', + customTitle: 'familiar dedup', + sessionId: VALID_CLAUDE_SESSION_ID, + }), + JSON.stringify({ + type: 'agent-name', + agentName: 'ignored older name', + sessionId: VALID_CLAUDE_SESSION_ID, + }), + ].join('\n')) + + expect(meta.title).toBe('familiar dedup') + }) + + it('falls back to the agent name when no custom title is present', () => { + const meta = parseSessionContent([ + JSON.stringify({ + type: 'user', + message: { role: 'user', content: 'Original prompt title' }, + timestamp: '2026-03-01T00:00:03.000Z', + }), + JSON.stringify({ + type: 'agent-name', + agentName: 'familiar dedup', + sessionId: VALID_CLAUDE_SESSION_ID, + }), + ].join('\n')) + + expect(meta.title).toBe('familiar dedup') + }) + it('treats flat-string assistant messages as semantic activity', () => { const meta = parseSessionContent([ JSON.stringify({