fix(tabs): drop the SSH transcript pull and slim the rename plumbing

The remote branch read the whole transcript through the SSH filesystem
provider on every non-decorative title frame — the contract has no ranged
read (`IFilesystemProvider.readFile` takes a path only), so a tab label
cost up to 8 MB over the link, per pane, on the mux that also carries PTY
data. `fs.search` is not a substitute: `--max-count 100` returns the
oldest matches, not the newest, so a long session would resolve a stale
rename.

Skip SSH-hosted panes in the renderer instead. That also stops main from
reading a remote path against the local filesystem, where it could
resolve to an unrelated session file. Covering SSH needs a bounded remote
tail read first.

Also folds the plumbing down: one `updateUnifiedTerminalLabelField`
replaces three near-identical unified-label updaters, the single-caller
`readClaudeSessionRenamedTitle` wrapper and the duplicated marker filter
are gone, and `stripLeadingAgentTitleDecoration` replaces its open-coded
equivalent.
This commit is contained in:
Neil
2026-08-07 20:54:14 -07:00
parent 93019a4500
commit 3ccfc12eec
11 changed files with 89 additions and 165 deletions
@@ -59,4 +59,8 @@ describe('readAgentSessionRenamedTitle', () => {
it('ignores an empty transcript path', async () => {
await expect(readAgentSessionRenamedTitle({ transcriptPath: ' ' })).resolves.toBeNull()
})
it('resolves null for an ill-typed transcript path instead of rejecting', async () => {
await expect(readAgentSessionRenamedTitle({ transcriptPath: 7 as never })).resolves.toBeNull()
})
})
+19 -59
View File
@@ -4,46 +4,32 @@ import { createInterface } from 'node:readline'
import {
CLAUDE_CUSTOM_TITLE_RECORD_MARKER,
findClaudeSessionRenameRecord,
readClaudeSessionRenamedTitle,
type ClaudeSessionRenameRecord
} from '../shared/agent-session-rename-title'
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
// Why: Claude re-appends the rename record on most turns, so measured against
// real transcripts (2.8 MB / 37 records, newest 403 B from EOF) a short tail
// answers nearly every scan. A rename that is never re-appended still exists
// only once, near the top, so an empty tail widens to a full scan.
// answers nearly every scan. A rename that is never re-appended exists only
// once, near the top, so an empty tail widens.
const TRANSCRIPT_TAIL_SCAN_BYTES = 256 * 1024
// Why: a rename record is one short line anywhere in the transcript, so the scan
// streams the file rather than buffering it. Very long sessions are tail-scanned
// from this offset; a rename that old is already superseded by the tab's
// persisted value.
// Why: the widened scan streams rather than buffers, but cap how far back it
// starts — a rename that old is already superseded by the tab's persisted value.
const MAX_TRANSCRIPT_SCAN_BYTES = 64 * 1024 * 1024
const MAX_REMOTE_TRANSCRIPT_READ_BYTES = 8 * 1024 * 1024
export type AgentSessionRenamedTitleArgs = {
transcriptPath: string
/** SSH connection owning the transcript; omitted for local/WSL panes. */
connectionId?: string
}
/** Local transcripts only — the renderer skips panes hosted over SSH. */
export type AgentSessionRenamedTitleArgs = { transcriptPath: string }
/**
* The deliberate rename (Claude `/rename`) recorded in one agent transcript, or
* null when the session has none. Best-effort: any read failure resolves null so
* the tab keeps its generated title instead of the call rejecting.
* null when the session has none. Best-effort: any read failure — including an
* ill-typed IPC payload — resolves null so the tab keeps its generated title.
*/
export async function readAgentSessionRenamedTitle(
args: AgentSessionRenamedTitleArgs
): Promise<string | null> {
const transcriptPath = args.transcriptPath.trim()
if (!transcriptPath) {
return null
}
const connectionId = args.connectionId?.trim()
try {
return connectionId
? await readRemoteRenamedTitle(transcriptPath, connectionId)
: await readLocalRenamedTitle(transcriptPath)
const transcriptPath = args.transcriptPath.trim()
return transcriptPath ? await readLocalRenamedTitle(transcriptPath) : null
} catch {
return null
}
@@ -69,42 +55,16 @@ async function scanTranscriptFrom(
): Promise<ClaudeSessionRenameRecord | null> {
const stream = createReadStream(transcriptPath, { encoding: 'utf8', start })
try {
const lines = createInterface({ input: stream, crlfDelay: Infinity })
return findClaudeSessionRenameRecord(await collectLines(lines))
// Why: transcripts are mostly long message records — keep only the lines
// that can carry a rename instead of buffering the file.
const candidates: string[] = []
for await (const line of createInterface({ input: stream, crlfDelay: Infinity })) {
if (line.includes(CLAUDE_CUSTOM_TITLE_RECORD_MARKER)) {
candidates.push(line)
}
}
return findClaudeSessionRenameRecord(candidates)
} finally {
stream.destroy()
}
}
async function collectLines(lines: AsyncIterable<string>): Promise<string[]> {
const collected: string[] = []
for await (const line of lines) {
if (line.includes(CLAUDE_CUSTOM_TITLE_RECORD_MARKER)) {
collected.push(line)
}
}
return collected
}
async function readRemoteRenamedTitle(
transcriptPath: string,
connectionId: string
): Promise<string | null> {
// Why: SSH-hosted agents write the transcript on the remote host, so the local
// filesystem has nothing to scan. No provider (disconnected) degrades to null.
const provider = getSshFilesystemProvider(connectionId)
if (!provider) {
return null
}
// Why: the remote read has no tail seek, so cap it well below the local budget
// rather than pulling a long session over the link for one short record.
const { size } = await provider.stat(transcriptPath)
if (size > MAX_REMOTE_TRANSCRIPT_READ_BYTES) {
return null
}
const { content, isBinary } = await provider.readFile(transcriptPath)
if (isBinary || !content) {
return null
}
return readClaudeSessionRenamedTitle(content.split('\n'))
}
+2 -9
View File
@@ -14,14 +14,7 @@ export function registerAgentSessionRenameHandlers(): void {
ipcMain.removeHandler('agentSession:getRenamedTitle')
ipcMain.handle(
'agentSession:getRenamedTitle',
async (_event, args?: AgentSessionRenamedTitleArgs): Promise<string | null> => {
if (!args || typeof args.transcriptPath !== 'string') {
return null
}
return readAgentSessionRenamedTitle({
transcriptPath: args.transcriptPath,
...(typeof args.connectionId === 'string' ? { connectionId: args.connectionId } : {})
})
}
async (_event, args?: AgentSessionRenamedTitleArgs): Promise<string | null> =>
typeof args?.transcriptPath === 'string' ? readAgentSessionRenamedTitle(args) : null
)
}
+2 -5
View File
@@ -2468,11 +2468,8 @@ export type PreloadApi = {
}) => Promise<void>
}
agentSession: {
/** Deliberate in-agent rename (Claude `/rename`) recorded in a transcript. */
getRenamedTitle: (args: {
transcriptPath: string
connectionId?: string
}) => Promise<string | null>
/** Deliberate in-agent rename (Claude `/rename`) recorded in a local transcript. */
getRenamedTitle: (args: { transcriptPath: string }) => Promise<string | null>
}
preflight: PreflightApi
notifications: {
+2 -4
View File
@@ -2140,10 +2140,8 @@ const api = {
},
agentSession: {
getRenamedTitle: (args: {
transcriptPath: string
connectionId?: string
}): Promise<string | null> => ipcRenderer.invoke('agentSession:getRenamedTitle', args)
getRenamedTitle: (args: { transcriptPath: string }): Promise<string | null> =>
ipcRenderer.invoke('agentSession:getRenamedTitle', args)
},
preflight: {
@@ -1,13 +1,11 @@
import { isAgentRenamedTerminalTitle } from '../../../shared/agent-session-rename-title'
// Why: a live OSC title change is the only immediate signal that a deliberate
// `/rename` may have happened — the agent fires no hook for it. One refresh per
// tab is in flight at a time so a burst of title frames cannot fan out into
// concurrent transcript scans; the newest frame of the burst is queued rather
// than dropped, or the tab would keep a rename verdict for a title it no longer
// shows until some later title change happens to arrive.
const inFlightTabIds = new Set<string>()
const queuedByTabId = new Map<string, AgentRenamedTabTitleRefreshArgs>()
// `/rename` may have happened — the agent fires no hook for it. Keyed entry
// means a scan is open for that tab; its value is the newest frame waiting
// behind it. Queuing rather than dropping matters because Claude stops
// auto-titling after a rename, so a dropped frame has no later trigger.
const openRefreshByTabId = new Map<string, AgentRenamedTabTitleRefreshArgs | null>()
export type AgentRenamedTabTitleRefreshArgs = {
tabId: string
@@ -15,7 +13,6 @@ export type AgentRenamedTabTitleRefreshArgs = {
liveTitle: string
/** Transcript per pane of the tab — a split tab can run several sessions. */
transcriptPaths: readonly string[]
connectionId: string | null
apply: (agentRenamedTitle: string | null) => void
}
@@ -27,18 +24,15 @@ export function scheduleAgentRenamedTabTitleRefresh(args: AgentRenamedTabTitleRe
if (!getRenamedTitle || args.transcriptPaths.length === 0) {
return
}
if (inFlightTabIds.has(args.tabId)) {
queuedByTabId.set(args.tabId, args)
if (openRefreshByTabId.has(args.tabId)) {
openRefreshByTabId.set(args.tabId, args)
return
}
inFlightTabIds.add(args.tabId)
openRefreshByTabId.set(args.tabId, null)
void (async () => {
try {
for (const transcriptPath of args.transcriptPaths) {
const renamedTitle = await getRenamedTitle({
transcriptPath,
...(args.connectionId ? { connectionId: args.connectionId } : {})
})
const renamedTitle = await getRenamedTitle({ transcriptPath })
if (isAgentRenamedTerminalTitle(args.liveTitle, renamedTitle)) {
args.apply(renamedTitle)
return
@@ -48,10 +42,9 @@ export function scheduleAgentRenamedTabTitleRefresh(args: AgentRenamedTabTitleRe
} catch {
// Best-effort: an unreadable transcript leaves the generated title in place.
} finally {
inFlightTabIds.delete(args.tabId)
const queued = queuedByTabId.get(args.tabId)
const queued = openRefreshByTabId.get(args.tabId)
openRefreshByTabId.delete(args.tabId)
if (queued) {
queuedByTabId.delete(args.tabId)
scheduleAgentRenamedTabTitleRefresh(queued)
}
}
@@ -5,7 +5,7 @@ import {
resolveTerminalTabTitle,
resolveUnifiedTabLabel
} from '../../../../shared/tab-title-resolution'
import { createTestStore, makeWorktree, seedStore } from './store-test-helpers'
import { createTestStore, makeWorktree, seedStore, TEST_REPO } from './store-test-helpers'
const WORKTREE_ID = 'repo1::/path/wt1'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
@@ -130,6 +130,17 @@ describe('deliberate in-agent rename vs generated tab title', () => {
)
})
it('does not scan an SSH-hosted pane, whose transcript lives on the remote host', async () => {
const store = createTestStore()
const tabId = seedAgentTab(store)
store.setState({ repos: [{ ...TEST_REPO, connectionId: 'ssh-1' }] })
store.getState().updateTabTitle(tabId, '✳ billing-fix')
await Promise.resolve()
expect(getRenamedTitle).not.toHaveBeenCalled()
})
it('does not scan transcripts while generated titles are off', async () => {
const store = createTestStore()
const tabId = seedAgentTab(store)
+22 -45
View File
@@ -333,52 +333,19 @@ function getTerminalTabOwnerWorktreeId(
return terminalTabOwnerCache.get(tabId) ?? null
}
function updateUnifiedTerminalLabel(
unifiedTabs: Tab[],
terminalTabId: string,
label: string
): Tab[] | null {
/** Null when the terminal has no unified tab or the field already holds `value`. */
function updateUnifiedTerminalLabelField<
K extends 'label' | 'generatedLabel' | 'agentRenamedLabel'
>(unifiedTabs: Tab[], terminalTabId: string, field: K, value: Tab[K]): Tab[] | null {
const unifiedIndex = unifiedTabs.findIndex(
(entry) => entry.contentType === 'terminal' && entry.entityId === terminalTabId
)
if (unifiedIndex === -1 || unifiedTabs[unifiedIndex]?.label === label) {
return null
}
return unifiedTabs.map((entry, index) => (index === unifiedIndex ? { ...entry, label } : entry))
}
function updateUnifiedTerminalAgentRenamedLabel(
unifiedTabs: Tab[],
terminalTabId: string,
agentRenamedLabel: string | null
): Tab[] | null {
const unifiedIndex = unifiedTabs.findIndex(
(entry) => entry.contentType === 'terminal' && entry.entityId === terminalTabId
)
if (
unifiedIndex === -1 ||
(unifiedTabs[unifiedIndex]?.agentRenamedLabel ?? null) === agentRenamedLabel
) {
const current = unifiedTabs[unifiedIndex]
if (!current || (current[field] ?? null) === (value ?? null)) {
return null
}
return unifiedTabs.map((entry, index) =>
index === unifiedIndex ? { ...entry, agentRenamedLabel } : entry
)
}
function updateUnifiedTerminalGeneratedLabel(
unifiedTabs: Tab[],
terminalTabId: string,
generatedLabel: string
): Tab[] | null {
const unifiedIndex = unifiedTabs.findIndex(
(entry) => entry.contentType === 'terminal' && entry.entityId === terminalTabId
)
if (unifiedIndex === -1 || unifiedTabs[unifiedIndex]?.generatedLabel === generatedLabel) {
return null
}
return unifiedTabs.map((entry, index) =>
index === unifiedIndex ? { ...entry, generatedLabel } : entry
index === unifiedIndex ? { ...entry, [field]: value } : entry
)
}
@@ -501,6 +468,13 @@ function requestAgentRenamedTabTitle(get: () => AppState, tabId: string, liveTit
if (!ownerWorktreeId) {
return
}
// Why: an SSH-hosted agent writes its transcript on the remote host, and the
// remote filesystem contract has no ranged read — scanning it would pull the
// whole transcript over the link on every title frame. Skip instead, and never
// read the remote path locally where it could resolve to an unrelated file.
if (getRemoteConnectionIdForWorktree(state, ownerWorktreeId)) {
return
}
const transcriptPaths = Object.entries(state.agentStatusByPaneKey).flatMap(([paneKey, entry]) => {
const transcriptPath = entry?.providerSession?.transcriptPath?.trim()
return transcriptPath && getTabIdFromPaneKey(paneKey) === tabId ? [transcriptPath] : []
@@ -509,7 +483,6 @@ function requestAgentRenamedTabTitle(get: () => AppState, tabId: string, liveTit
tabId,
liveTitle,
transcriptPaths,
connectionId: getRemoteConnectionIdForWorktree(state, ownerWorktreeId),
apply: (agentRenamedTitle) => get().setTabAgentRenamedTitle(tabId, agentRenamedTitle)
})
}
@@ -2042,9 +2015,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
if (!currentTab || (currentTab.agentRenamedTitle ?? null) === agentRenamedTitle) {
return s
}
const unifiedTabsWithRenamedLabel = updateUnifiedTerminalAgentRenamedLabel(
const unifiedTabsWithRenamedLabel = updateUnifiedTerminalLabelField(
s.unifiedTabsByWorktree[ownerWorktreeId] ?? [],
tabId,
'agentRenamedLabel',
agentRenamedTitle
)
scheduleRuntimeGraphSync()
@@ -2084,9 +2058,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
const nextTitle = title.trim() || getFallbackTabTitle(currentTab)
const currentUnifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? []
if (isDecorativeAgentTitleFrameChange(currentTab.title, nextTitle)) {
const unifiedTabsWithCurrentLabel = updateUnifiedTerminalLabel(
const unifiedTabsWithCurrentLabel = updateUnifiedTerminalLabelField(
currentUnifiedTabs,
tabId,
'label',
currentTab.title
)
return unifiedTabsWithCurrentLabel
@@ -2098,9 +2073,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
}
: s
}
const unifiedTabsWithUpdatedLabel = updateUnifiedTerminalLabel(
const unifiedTabsWithUpdatedLabel = updateUnifiedTerminalLabelField(
currentUnifiedTabs,
tabId,
'label',
nextTitle
)
if (currentTab.title === nextTitle) {
@@ -2200,9 +2176,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
tab.id === tabId ? { ...tab, generatedTitle } : tab
)
const currentUnifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? []
const unifiedTabsWithGeneratedLabel = updateUnifiedTerminalGeneratedLabel(
const unifiedTabsWithGeneratedLabel = updateUnifiedTerminalLabelField(
currentUnifiedTabs,
tabId,
'generatedLabel',
generatedTitle
)
scheduleRuntimeGraphSync()
+5 -2
View File
@@ -9,7 +9,10 @@
import type { AgentType } from './agent-status-types'
import { isAgentRenamedTerminalTitle } from './agent-session-rename-title'
import { isClaudeManagementTitle } from './agent-title-core'
import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration'
import {
stripLeadingAgentTitleDecoration,
stripLeadingAgentTitleDecorationOrEmpty
} from './agent-title-decoration'
import { formatAgentTypeLabel } from './agent-type-label'
import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title'
import { SYNTHETIC_AGENT_TITLE_PROFILES } from './synthetic-agent-title'
@@ -132,7 +135,7 @@ export function getAgentRowConversationName(
}
const liveTitle = tab.title?.trim() ?? ''
if (isAgentRenamedTerminalTitle(liveTitle, tab.agentRenamedTitle)) {
return stripLeadingAgentTitleDecorationOrEmpty(liveTitle) || liveTitle
return stripLeadingAgentTitleDecoration(liveTitle)
}
if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) {
return liveTitle
@@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest'
import {
findClaudeSessionRenameRecord,
isAgentRenamedTerminalTitle,
readClaudeSessionRenamedTitle
isAgentRenamedTerminalTitle
} from './agent-session-rename-title'
const readClaudeSessionRenamedTitle = (lines: Iterable<string>): string | null =>
findClaudeSessionRenameRecord(lines)?.customTitle ?? null
// Records copied from a real Claude transcript for a session that answered one
// prompt and was then renamed with `/rename billing-fix`.
const AI_TITLE_LINE = JSON.stringify({
@@ -18,7 +20,7 @@ const CUSTOM_TITLE_LINE = JSON.stringify({
sessionId: 'ac1a3f53-82b1-41ab-8db8-028c1fc15c48'
})
describe('readClaudeSessionRenamedTitle', () => {
describe('the rename a Claude transcript currently records', () => {
it('reads the deliberate rename and ignores auto-generated titles', () => {
expect(readClaudeSessionRenamedTitle([AI_TITLE_LINE, CUSTOM_TITLE_LINE])).toBe('billing-fix')
})
+5 -19
View File
@@ -9,9 +9,8 @@ import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoratio
/** Cheap prefilter for transcript readers before they buffer or parse a line. */
export const CLAUDE_CUSTOM_TITLE_RECORD_MARKER = '"custom-title"'
/** The newest rename record in a range of transcript lines. `customTitle` is
* null when the agent cleared the name — still a record, so callers must not
* keep searching earlier ranges for an older one. */
/** `customTitle` is null when the agent cleared the name — still a record, so
* callers must not keep searching earlier ranges for an older one. */
export type ClaudeSessionRenameRecord = { customTitle: string | null }
/**
@@ -36,27 +35,14 @@ export function findClaudeSessionRenameRecord(
} catch {
continue
}
if (!parsed || typeof parsed !== 'object') {
continue
const { type, customTitle } = (parsed ?? {}) as { type?: unknown; customTitle?: unknown }
if (type === 'custom-title') {
record = { customTitle: typeof customTitle === 'string' ? customTitle.trim() || null : null }
}
const { type, customTitle } = parsed as { type?: unknown; customTitle?: unknown }
if (type !== 'custom-title') {
continue
}
record = { customTitle: typeof customTitle === 'string' ? customTitle.trim() || null : null }
}
return record
}
/**
* The session's current deliberate rename from Claude transcript lines, or null
* when none survives. Later records win; an empty `customTitle` is Claude
* clearing the name, which must fall back to Orca's generated title.
*/
export function readClaudeSessionRenamedTitle(lines: Iterable<string>): string | null {
return findClaudeSessionRenameRecord(lines)?.customTitle ?? null
}
/**
* True when the pane's live OSC title still carries the recorded deliberate
* rename. Matching (rather than substituting the recorded name) keeps the label