Skip to content
Merged
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
11 changes: 10 additions & 1 deletion server/coding-cli/providers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -496,7 +505,7 @@ export function parseSessionContent(content: string, options: ParseSessionOption
cwd,
createdAt,
lastActivityAt,
title,
title: customTitle ?? agentName ?? title,
summary,
firstUserMessage,
messageCount: lines.length,
Expand Down
24 changes: 9 additions & 15 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -508,33 +509,27 @@ async function main() {
sessionsSync.publish(projects)
const associationMetaUpserts: ReturnType<TerminalMetadataService['list']> = []
const pendingMetadataSync = new Map<string, CodingCliSession>()
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')
}
}

Expand Down Expand Up @@ -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) => {
Expand Down
27 changes: 27 additions & 0 deletions server/session-association-updates.ts
Original file line number Diff line number Diff line change
@@ -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<SessionAssociationCoordinator, 'collectNewOrAdvanced' | 'associateSingleSession'>

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
}
33 changes: 11 additions & 22 deletions test/server/session-association.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => ({
Expand Down Expand Up @@ -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,
})
}
}

Expand Down
39 changes: 39 additions & 0 deletions test/unit/server/coding-cli/claude-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading