From 8fcdb67cb7a326cc372dce4526a818fd853f70ec Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Sun, 13 Sep 2026 18:49:38 +0300 Subject: [PATCH] coding-agent: attach with the daemon summary instead of parsing the transcript --- .../coding-agent/src/core/session-manager.ts | 19 ++++++ packages/coding-agent/src/core/timings.ts | 4 +- packages/coding-agent/src/main.ts | 66 ++++++++++++++++--- .../src/modes/interactive/interactive-mode.ts | 18 ++++- 4 files changed, 94 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 97d998e56..b903e4e4c 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -739,6 +739,25 @@ export function loadEntriesFromFile(filePath: string): FileEntry[] { return finalizeLoadedEntries(parseEntriesFromBuffer(readFileSync(filePath))); } +/** + * Read only the session header entry. Callers that already hold the session + * summary (daemon attach, selector resolution) use it to avoid parsing a whole + * transcript just to learn the session's cwd and id. Returns undefined when the + * first physical line is not a session header, so callers fall back to the full + * load for legacy files. + */ +export function loadSessionHeaderEntry(filePath: string): FileEntry[] | undefined { + if (!existsSync(filePath)) return undefined; + const firstLine = readFirstLineSync(filePath); + if (!firstLine) return undefined; + try { + const entry = JSON.parse(firstLine) as FileEntry; + return entry && typeof entry === "object" && entry.type === "session" ? [entry] : undefined; + } catch { + return undefined; + } +} + // Async loader for the daemon: reads off the event loop and yields while parsing so a // large load doesn't freeze other sessions. Large files stream to avoid retaining both // the full input Buffer and the parsed entry graph at the same time. diff --git a/packages/coding-agent/src/core/timings.ts b/packages/coding-agent/src/core/timings.ts index 677f0d886..ddfe08b71 100644 --- a/packages/coding-agent/src/core/timings.ts +++ b/packages/coding-agent/src/core/timings.ts @@ -27,7 +27,9 @@ export function printTimings(): void { if (!ENABLED || timings.length === 0) return; const totalMs = timings.reduce((a, b) => a + b.ms, 0); log.debug("startup timings", { timings: [...timings], totalMs }); - console.error("\n--- Startup Timings ---"); + console.error( + `\n--- Startup Timings --- (process uptime ${Math.round(process.uptime() * 1000)}ms, total ${totalMs}ms)`, + ); for (const t of timings) { console.error(` ${t.label}: ${t.ms}ms`); } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 1e9b15d86..0cf6e0415 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -72,6 +72,7 @@ import { findMostRecentSessionForCwd, getDefaultSessionDir, loadEntriesFromFile, + loadSessionHeaderEntry, SessionManager, } from "./core/session-manager.js"; import { SettingsManager } from "./core/settings-manager.js"; @@ -438,8 +439,13 @@ function getResumeSelector(parsed: Pick): string | undefined { return typeof parsed.resume === "string" ? parsed.resume : undefined; } -function readSessionManager(path: string, sessionDir?: string, cwdOverride?: string): SessionManager { - const entries = loadEntriesFromFile(path); +function readSessionManager( + path: string, + sessionDir?: string, + cwdOverride?: string, + options?: { headerOnly?: boolean }, +): SessionManager { + const entries = (options?.headerOnly ? loadSessionHeaderEntry(path) : undefined) ?? loadEntriesFromFile(path); const header = entries.find((entry) => entry.type === "session"); const manager = SessionManager.inMemory( cwdOverride ?? header?.cwd ?? process.cwd(), @@ -808,6 +814,12 @@ async function prepareRuntimeServices(options: { sessionManager: SessionManager; extensionFactories?: ExtensionFactory[]; sessionOptionsOverride?: CreateAgentSessionOptions; + /** + * Session facts a caller already knows (a daemon attach summary). Supplying + * them keeps a client that holds only the session header from parsing the + * whole transcript to answer "resuming?" and "which model?". + */ + existingSession?: ExistingSessionFacts; }): Promise { const { config, sessionManager } = options; const effectiveAgentDir = config.agentDir ?? options.agentDir; @@ -867,7 +879,7 @@ async function prepareRuntimeServices(options: { } = buildSessionOptions( effectiveConfig, scopedModels, - sessionManager.buildSessionContext().messages.length > 0, + options.existingSession?.hasMessages ?? sessionManager.buildSessionContext().messages.length > 0, modelRegistry, settingsManager, ); @@ -894,25 +906,33 @@ async function prepareRuntimeServices(options: { }; } +/** Session facts a caller already knows, so the transcript need not be parsed. */ +interface ExistingSessionFacts { + hasMessages: boolean; + model: { provider: string; modelId: string } | null; +} + async function resolvePreparedStartupModel(options: { prepared: PreparedRuntimeServices; sessionManager: SessionManager; + existingSession?: ExistingSessionFacts; }): Promise<{ model: Model | undefined; modelFallbackMessage: string | undefined }> { const { prepared, sessionManager } = options; const { modelRegistry, settingsManager } = prepared.services; - const existingSession = sessionManager.buildSessionContext(); - const hasExistingSession = existingSession.messages.length > 0; + const sessionContext = options.existingSession ? undefined : sessionManager.buildSessionContext(); + const hasExistingSession = options.existingSession?.hasMessages ?? sessionContext!.messages.length > 0; + const sessionModel = options.existingSession ? options.existingSession.model : (sessionContext!.model ?? null); let model = prepared.sessionOptions.model; let modelFallbackMessage: string | undefined; - if (!model && hasExistingSession && existingSession.model) { - const restoredModel = modelRegistry.find(existingSession.model.provider, existingSession.model.modelId); + if (!model && hasExistingSession && sessionModel) { + const restoredModel = modelRegistry.find(sessionModel.provider, sessionModel.modelId); if (restoredModel && modelRegistry.hasConfiguredAuth(restoredModel)) { model = restoredModel; } if (!model) { - modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`; + modelFallbackMessage = `Could not restore model ${sessionModel.provider}/${sessionModel.modelId}`; } } @@ -1023,7 +1043,10 @@ function createSessionManagerForActiveDaemonSummary(summary: SessionSummary, fal const cwd = summary.cwd || fallbackCwd; if (summary.sessionFile) { try { - return readSessionManager(summary.sessionFile, undefined, cwd); + // The summary already carries the cwd, id and model, so the client only + // needs the header: parsing a multi-megabyte transcript here would cost + // tens of milliseconds before the first frame. + return readSessionManager(summary.sessionFile, undefined, cwd, { headerOnly: true }); } catch { return SessionManager.inMemory(cwd); } @@ -1406,17 +1429,36 @@ export async function main(args: string[], options?: MainOptions) { return; } if (useDaemonInteractive) { + // The daemon owns the sessions it attaches, so its summary is the authority + // on whether the session has messages and which model it runs. Using it + // keeps the client from parsing the transcript to re-derive both. + const daemonSessionFacts: ExistingSessionFacts | undefined = activeDaemonSessionSummary + ? { + hasMessages: (activeDaemonSessionSummary.messageCount ?? 0) > 0, + model: activeDaemonSessionSummary.model + ? { + provider: activeDaemonSessionSummary.model.provider, + modelId: activeDaemonSessionSummary.model.id, + } + : null, + } + : undefined; const prepared = await prepareRuntimeServices({ config: defaultSessionConfig, cwd: sessionManager.getCwd(), agentDir, sessionManager, extensionFactories: options?.extensionFactories, + existingSession: daemonSessionFacts, }); const { services, scopedModels } = prepared; const { settingsManager } = services; - const startupModel = await resolvePreparedStartupModel({ prepared, sessionManager }); + const startupModel = await resolvePreparedStartupModel({ + prepared, + sessionManager, + existingSession: daemonSessionFacts, + }); const stdinContent = await readPipedStdin(); time("readPipedStdin"); @@ -1504,6 +1546,7 @@ export async function main(args: string[], options?: MainOptions) { } daemonReady = (await awaitDaemonReady(daemonReady)).ready; + time("awaitDaemonReady"); // A fresh default chat opens a real but message-less session; the lifecycle // axis treats it as a draft (hidden, discarded on detach if never used), so // no DeferredAgentConnection is needed to avoid creating it up front. @@ -1530,6 +1573,7 @@ export async function main(args: string[], options?: MainOptions) { } throw error; } + time("createDaemonClientConnection"); const agentConnection: AgentConnection = connection; const attachModelFallbackMessage = isFreshDefaultSession ? startupModel.modelFallbackMessage @@ -1559,8 +1603,10 @@ export async function main(args: string[], options?: MainOptions) { // child snapshots augment this running-child fallback inside chat. sessionHasChildren: summary.hasRunningRlmChildren === true, }); + time("interactiveMode.ctor"); await preloadCodeHighlighter(); + time("preloadCodeHighlighter"); printTimings(); const interactiveResult = await interactiveMode.run(); if (parsed.noSession) { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index c0dc95c72..b20b50989 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -177,6 +177,7 @@ import { captureOnboardingCompleted, type TelemetryOnboardingOutcome, } from "../../core/telemetry.js"; +import { time } from "../../core/timings.js"; import { type TruncationResult, truncateTail } from "../../core/tools/truncate.js"; import { PRIME_COMPACT_BUTTERFLY_LOGO } from "../../themes/prime-logo.js"; import { getChangelogPath, parseChangelog } from "../../utils/changelog.js"; @@ -1684,6 +1685,7 @@ export class InteractiveMode { // Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir) // fd powers autocomplete, and rg is available for shell commands. const [fdPath, rgResult] = await Promise.all([ensureTool("fd"), ensureToolWithStatus("rg")]); + time("init.ensureTools"); this.fdPath = fdPath; this.ui.addChild(this.headerContainer); @@ -1754,8 +1756,10 @@ export class InteractiveMode { this.setupKeyHandlers(); this.setupEditorSubmitHandler(); + time("init.buildUi"); this.ui.start(); + time("init.uiStart"); this.fullscreenEnabled = (this.options.forceFullscreen === true || this.settingsManager.getFullscreen()) && process.stdout.isTTY === true; @@ -1765,11 +1769,13 @@ export class InteractiveMode { this.isInitialized = true; await this.rebindCurrentSession(); + time("init.rebindCurrentSession"); await this.renderInitialMessages(); if (rgResult.status === "unavailable") { this.showWarning(formatMissingRipgrepMessage(rgResult)); } + time("init.renderInitialMessages");>>>>>>> cff66a839 (coding-agent: attach with the daemon summary instead of parsing the transcript) onThemeChange(() => { this.ui.invalidate(); @@ -1781,7 +1787,9 @@ export class InteractiveMode { this.ui.requestRender(); }); - await this.updateAvailableProviderCount(); + // Needs a model-list round trip; the footer count must not hold up the first frame. + void this.updateAvailableProviderCount(); + time("init.providerCount"); } /** @@ -1835,6 +1843,7 @@ export class InteractiveMode { this.initializationPromise = this.init(); await this.initializationPromise; this.restorePromptStashOnOpen(); + time("run.afterInit"); // Global, environment-scoped notices (app update, extension updates, tmux setup) // belong on the agents view, not in a conversation. When the agents view already @@ -2022,14 +2031,16 @@ export class InteractiveMode { if (!this.isShuttingDown && !this.isReturningToAgentsView) { await this.runStartupOnboarding(); + time("run.onboarding"); showDeferredStartupNotifications(); showModelFallbackWarning(); void this.maybeWarnAboutAnthropicSubscriptionAuth(); + time("run.startupNotices"); void deliverStartupPrompts().then( () => settleStartupPrompts("admitted"), () => settleStartupPrompts("admitted"), ); - } + }>>>>>>> cff66a839 (coding-agent: attach with the daemon summary instead of parsing the transcript) // Enter/Alt+Enter submit directly through AgentConnection. Wait for the // lifecycle signal exactly once; a returned editor value has already been @@ -8737,6 +8748,9 @@ export class InteractiveMode { const models = await this.getModelCandidates(); const uniqueProviders = new Set(models.map((m) => m.provider)); this.footerDataProvider.setAvailableProviderCount(uniqueProviders.size); + if (this.isInitialized) { + this.ui.requestRender(); + } } private async maybeWarnAboutAnthropicSubscriptionAuth( -- 2.54.0