From 266d2eeda12d0f10e4ab1b4fc01fe88ff4b9eb12 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 20 Jul 2026 22:23:04 +0800 Subject: [PATCH 1/5] feat(expose): warm previews from project service cache --- src/daemon.ts | 16 ++-- src/expose-preview-cache.test.ts | 78 +++++++++++++++++ src/expose-preview-cache.ts | 142 +++++++++++++++++++++++++++++++ src/metadata-server.test.ts | 65 ++++++++++++++ src/metadata-server.ts | 33 +++++-- 5 files changed, 323 insertions(+), 11 deletions(-) create mode 100644 src/expose-preview-cache.test.ts create mode 100644 src/expose-preview-cache.ts diff --git a/src/daemon.ts b/src/daemon.ts index e074afc7..1b5e1335 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -8,6 +8,7 @@ import { loadMetadataEndpointByProjectId, removeMetadataEndpoint } from "./metad import { requestJson } from "./http-client.js"; import { log } from "./debug.js"; import { listAllProjectsExposeItems } from "./expose-control.js"; +import { getExposePreviewSnapshot } from "./expose-preview-cache.js"; import { RelayClient, type RelayNotificationPush, type RelayStatusSnapshot } from "./relay-client.js"; import { MobilePushThrottle } from "./mobile-push-throttle.js"; import { clearCredentials, loadCredentials, setRemoteEnabled } from "./credentials.js"; @@ -664,12 +665,15 @@ export class AimuxDaemon { } private exposeItemsRoute(): DaemonRouteResponse { - const items = listAllProjectsExposeItems().map((item) => ({ - ...serializeFastControlItem(item), - projectId: item.projectId, - projectName: item.projectName, - projectRoot: item.projectRoot, - })); + const items = listAllProjectsExposeItems().map((item) => { + const previewSnapshot = getExposePreviewSnapshot(item.projectRoot, item.target.windowId); + return { + ...serializeFastControlItem(previewSnapshot ? { ...item, previewSnapshot } : item), + projectId: item.projectId, + projectName: item.projectName, + projectRoot: item.projectRoot, + }; + }); return { status: 200, body: { ok: true, items } }; } diff --git a/src/expose-preview-cache.test.ts b/src/expose-preview-cache.test.ts new file mode 100644 index 00000000..273ee858 --- /dev/null +++ b/src/expose-preview-cache.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import { ExposePreviewCache, EXPOSE_PREVIEW_CAPTURE_LINES, getExposePreviewSnapshot } from "./expose-preview-cache.js"; +import type { FastControlItem } from "./fast-control.js"; + +function item(id: string, windowId: string): Pick { + return { + id, + target: { sessionName: "aimux-test", windowId, windowIndex: 1, windowName: "codex" }, + }; +} + +describe("ExposePreviewCache", () => { + it("captures listed targets as preview snapshots", () => { + const tmux = { + captureTarget: vi.fn((target) => `output for ${target.windowId}\n`), + }; + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux, + listItems: () => [item("a", "@1"), item("b", "@2")], + now: () => new Date("2026-07-20T13:00:00.000Z"), + }); + + cache.refreshNow(); + + expect(tmux.captureTarget).toHaveBeenCalledWith(expect.objectContaining({ windowId: "@1" }), { + startLine: -EXPOSE_PREVIEW_CAPTURE_LINES, + includeEscapes: true, + }); + expect(cache.get("@1")).toEqual({ + output: "output for @1\n", + capturedAt: "2026-07-20T13:00:00.000Z", + source: "capture", + windowId: "@1", + startLine: -EXPOSE_PREVIEW_CAPTURE_LINES, + lineCount: EXPOSE_PREVIEW_CAPTURE_LINES, + }); + }); + + it("keeps the last good snapshot when capture fails", () => { + const tmux = { + captureTarget: vi.fn(() => "first output\n"), + }; + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux, + listItems: () => [item("a", "@1")], + now: () => new Date("2026-07-20T13:00:00.000Z"), + }); + + cache.refreshNow(); + tmux.captureTarget.mockImplementation(() => { + throw new Error("tmux unavailable"); + }); + cache.refreshNow(); + + expect(cache.get("@1")?.output).toBe("first output\n"); + }); + + it("registers running caches for daemon global expose responses", () => { + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux: { captureTarget: () => "registered output\n" }, + listItems: () => [item("a", "@1")], + now: () => new Date("2026-07-20T13:00:00.000Z"), + }); + + cache.start(); + try { + cache.refreshNow(); + expect(getExposePreviewSnapshot("/repo", "@1")?.output).toBe("registered output\n"); + expect(getExposePreviewSnapshot("/repo/../repo", "@1")?.output).toBe("registered output\n"); + } finally { + cache.stop(); + } + expect(getExposePreviewSnapshot("/repo", "@1")).toBeUndefined(); + }); +}); diff --git a/src/expose-preview-cache.ts b/src/expose-preview-cache.ts new file mode 100644 index 00000000..f43a4553 --- /dev/null +++ b/src/expose-preview-cache.ts @@ -0,0 +1,142 @@ +import { resolve as pathResolve } from "node:path"; +import type { FastControlItem } from "./fast-control.js"; +import type { ExposePreviewSnapshot } from "./project-api-contract.js"; +import { TmuxRuntimeManager } from "./tmux/runtime-manager.js"; +import type { TmuxTarget } from "./tmux/runtime-manager.js"; + +export const EXPOSE_PREVIEW_CAPTURE_LINES = 40; +const EXPOSE_PREVIEW_REFRESH_MS = 1000; + +type ExposePreviewTarget = Pick; + +export interface ExposePreviewCacheLike { + start(): void; + stop(): void; + trackItems(items: ExposePreviewTarget[]): void; + get(windowId: string): ExposePreviewSnapshot | undefined; +} + +export interface ExposePreviewCacheOptions { + projectRoot: string; + tmux?: Pick; + listItems: () => ExposePreviewTarget[]; + intervalMs?: number; + lineCount?: number; + now?: () => Date; +} + +const cachesByProjectRoot = new Map(); + +function normalizedProjectRoot(projectRoot: string): string { + return pathResolve(projectRoot); +} + +export function getExposePreviewSnapshot(projectRoot: string, windowId: string): ExposePreviewSnapshot | undefined { + return cachesByProjectRoot.get(normalizedProjectRoot(projectRoot))?.get(windowId); +} + +function registerExposePreviewCache(projectRoot: string, cache: ExposePreviewCacheLike): void { + cachesByProjectRoot.set(normalizedProjectRoot(projectRoot), cache); +} + +function unregisterExposePreviewCache(projectRoot: string, cache: ExposePreviewCacheLike): void { + const normalized = normalizedProjectRoot(projectRoot); + if (cachesByProjectRoot.get(normalized) === cache) cachesByProjectRoot.delete(normalized); +} + +export class ExposePreviewCache implements ExposePreviewCacheLike { + private readonly tmux: Pick; + private readonly intervalMs: number; + private readonly lineCount: number; + private readonly now: () => Date; + private readonly snapshots = new Map(); + private readonly trackedTargets = new Map(); + private timer: ReturnType | null = null; + private running = false; + private refreshing = false; + + constructor(private readonly options: ExposePreviewCacheOptions) { + this.tmux = options.tmux ?? new TmuxRuntimeManager(); + this.intervalMs = options.intervalMs ?? EXPOSE_PREVIEW_REFRESH_MS; + this.lineCount = options.lineCount ?? EXPOSE_PREVIEW_CAPTURE_LINES; + this.now = options.now ?? (() => new Date()); + } + + start(): void { + if (this.running) return; + this.running = true; + registerExposePreviewCache(this.options.projectRoot, this); + this.schedule(0); + } + + stop(): void { + this.running = false; + unregisterExposePreviewCache(this.options.projectRoot, this); + if (this.timer) clearTimeout(this.timer); + this.timer = null; + } + + trackItems(items: ExposePreviewTarget[]): void { + for (const item of items) { + this.trackedTargets.set(item.target.windowId, item); + } + } + + get(windowId: string): ExposePreviewSnapshot | undefined { + return this.snapshots.get(windowId); + } + + refreshNow(): void { + this.refresh(); + } + + private schedule(delayMs = this.intervalMs): void { + if (!this.running || this.timer) return; + this.timer = setTimeout(() => { + this.timer = null; + this.refresh(); + }, delayMs); + this.timer.unref?.(); + } + + private listedTargets(): ExposePreviewTarget[] { + try { + return this.options.listItems(); + } catch { + return []; + } + } + + private refresh(): void { + if (this.refreshing) return; + this.refreshing = true; + try { + const targets = new Map(); + for (const item of this.listedTargets()) targets.set(item.target.windowId, item); + for (const item of this.trackedTargets.values()) targets.set(item.target.windowId, item); + for (const item of targets.values()) this.capture(item.target); + } finally { + this.refreshing = false; + this.schedule(); + } + } + + private capture(target: TmuxTarget): void { + try { + const output = this.tmux.captureTarget(target, { + startLine: -this.lineCount, + includeEscapes: true, + }); + this.snapshots.set(target.windowId, { + output, + capturedAt: this.now().toISOString(), + source: "capture", + windowId: target.windowId, + startLine: -this.lineCount, + lineCount: this.lineCount, + }); + } catch { + return; + } + } +} diff --git a/src/metadata-server.test.ts b/src/metadata-server.test.ts index ded622b8..ac9a0cd3 100644 --- a/src/metadata-server.test.ts +++ b/src/metadata-server.test.ts @@ -2090,6 +2090,71 @@ describe("MetadataServer threads API", () => { } }); + it("attaches cached expose preview snapshots to switchable-agent responses", async () => { + server?.stop(); + const getProjectSession = TmuxRuntimeManager.prototype.getProjectSession; + const listManagedWindows = TmuxRuntimeManager.prototype.listManagedWindows; + const listWindows = TmuxRuntimeManager.prototype.listWindows; + const isWindowAlive = TmuxRuntimeManager.prototype.isWindowAlive; + const previewSnapshot = { + output: "warm preview\n", + capturedAt: "2026-07-20T13:00:00.000Z", + source: "capture" as const, + windowId: "@7", + startLine: -40, + lineCount: 40, + }; + const exposePreviewCache = { + start: vi.fn(), + stop: vi.fn(), + trackItems: vi.fn(), + get: vi.fn((windowId: string) => (windowId === "@7" ? previewSnapshot : undefined)), + }; + + TmuxRuntimeManager.prototype.getProjectSession = () => ({ sessionName: "aimux-test" }) as any; + TmuxRuntimeManager.prototype.listManagedWindows = () => + [ + { + target: { sessionName: "aimux-test", windowId: "@7", windowIndex: 7, windowName: "codex" }, + metadata: { + kind: "agent", + sessionId: "agent-1", + command: "codex", + args: [], + toolConfigKey: "codex", + worktreePath: repoRoot, + }, + }, + ] as any; + TmuxRuntimeManager.prototype.listWindows = () => [ + { id: "@7", index: 7, name: "codex", active: true, activity: 12 }, + ]; + TmuxRuntimeManager.prototype.isWindowAlive = () => true; + server = new MetadataServer({ exposePreviewCache }); + await server.start(); + + try { + const endpoint = server.getAddress(); + expect(endpoint).toBeTruthy(); + const response = await fetch( + `http://${endpoint!.host}:${endpoint!.port}${PROJECT_API_ROUTES.controls.switchableAgents}?scope=all&labelFormat=raw`, + ); + const body = (await response.json()) as { ok: boolean; items: Array> }; + + expect(response.status).toBe(200); + expect(body.items[0]?.previewSnapshot).toEqual(previewSnapshot); + expect(exposePreviewCache.trackItems).toHaveBeenCalledWith([ + expect.objectContaining({ target: expect.objectContaining({ windowId: "@7" }) }), + ]); + expect(exposePreviewCache.start).toHaveBeenCalledTimes(1); + } finally { + TmuxRuntimeManager.prototype.getProjectSession = getProjectSession; + TmuxRuntimeManager.prototype.listManagedWindows = listManagedWindows; + TmuxRuntimeManager.prototype.listWindows = listWindows; + TmuxRuntimeManager.prototype.isWindowAlive = isWindowAlive; + } + }); + it("serves a reconciled coordination worklist from desktop state + notifications", async () => { server?.stop(); server = new MetadataServer({ diff --git a/src/metadata-server.ts b/src/metadata-server.ts index e91ee45e..7cf60fe6 100644 --- a/src/metadata-server.ts +++ b/src/metadata-server.ts @@ -158,6 +158,7 @@ import { import { loadConfig } from "./config.js"; import { describeSessionRestorability } from "./session-restorability.js"; import { shouldRelaunchFreshSession } from "./session-fresh-relaunch.js"; +import { ExposePreviewCache, type ExposePreviewCacheLike } from "./expose-preview-cache.js"; import { runTmuxExpose } from "./tmux/expose.js"; import { buildGraveyardViewModel } from "./multiplexer/graveyard-view-model.js"; import { @@ -642,6 +643,7 @@ export interface MetadataServerOptions { | Promise<{ sessionId: string; output: string; startLine?: number; parsed?: ParsedAgentOutput }> | { sessionId: string; output: string; startLine?: number; parsed?: ParsedAgentOutput }; }; + exposePreviewCache?: ExposePreviewCacheLike | false; } type InteractionDisplay = { @@ -1364,9 +1366,21 @@ export class MetadataServer { private shellStateFlushTimer: ReturnType | null = null; private exposeServer: NetServer | null = null; private exposeSocketPath: string | null = null; + private readonly exposePreviewCache: ExposePreviewCacheLike | null; constructor(private readonly options: MetadataServerOptions = {}) { this.projectRoot = options.projectRoot?.trim() || metadataProjectRoot(); + const defaultExposePreviewCache = options.lifecycle?.readAgentOutput + ? new ExposePreviewCache({ + projectRoot: this.currentProjectRoot(), + listItems: () => + listSwitchableAgentItems({ projectRoot: this.currentProjectRoot() }, new TmuxRuntimeManager(), { + scope: "all", + }), + }) + : null; + this.exposePreviewCache = + options.exposePreviewCache === false ? null : (options.exposePreviewCache ?? defaultExposePreviewCache); this.eventBus = options.events?.bus ?? new ProjectEventBus(); this.unsubscribeAlertSink = this.eventBus.subscribe((event) => { if (event.type !== "alert") return; @@ -1393,6 +1407,7 @@ export class MetadataServer { error: error instanceof Error ? error.message : String(error), }); }); + this.exposePreviewCache?.start(); } private publishEndpoint(): void { @@ -1418,6 +1433,7 @@ export class MetadataServer { this.server?.close(); this.server = null; this.stopExposeSocket(); + this.exposePreviewCache?.stop(); if (this.desktopStateRefreshTimer) clearTimeout(this.desktopStateRefreshTimer); this.desktopStateRefreshTimer = null; if (this.shellStateFlushTimer) clearTimeout(this.shellStateFlushTimer); @@ -2615,7 +2631,7 @@ export class MetadataServer { const currentPath = url.searchParams.get("currentPath")?.trim() || undefined; const scope = url.searchParams.get("scope") === "all" ? "all" : "worktree"; const rawLabels = url.searchParams.get("labelFormat") === "raw"; - const items = listSwitchableAgentItems( + const rawItems = listSwitchableAgentItems( { projectRoot: this.currentProjectRoot(), currentClientSession, @@ -2625,10 +2641,17 @@ export class MetadataServer { }, new TmuxRuntimeManager(), { scope }, - ).map((item) => ({ - ...serializeFastControlItem(item), - label: rawLabels || !item.lastUsedAt ? item.label : `${item.label} · ${formatRelativeRecency(item.lastUsedAt)}`, - })); + ); + this.exposePreviewCache?.trackItems(rawItems); + const items = rawItems.map((item) => { + const previewSnapshot = this.exposePreviewCache?.get(item.target.windowId); + const serialized = serializeFastControlItem(previewSnapshot ? { ...item, previewSnapshot } : item); + return { + ...serialized, + label: + rawLabels || !item.lastUsedAt ? item.label : `${item.label} · ${formatRelativeRecency(item.lastUsedAt)}`, + }; + }); send(res, 200, { ok: true, items }); return; } From 7a4099570335f5fccc9e2b196781e1b037f4b8c1 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 20 Jul 2026 22:40:06 +0800 Subject: [PATCH 2/5] fix(expose): make preview warming demand driven --- src/daemon.ts | 9 ++- src/expose-preview-cache.test.ts | 133 +++++++++++++++++++++++++++---- src/expose-preview-cache.ts | 65 +++++++++++---- src/metadata-server.test.ts | 11 ++- src/metadata-server.ts | 5 +- src/project-api-contract.ts | 1 + src/tmux/expose-model.test.ts | 3 + src/tmux/expose-model.ts | 2 + src/tmux/runtime-manager.ts | 7 ++ 9 files changed, 197 insertions(+), 39 deletions(-) diff --git a/src/daemon.ts b/src/daemon.ts index 1b5e1335..6a7ce0f3 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -664,9 +664,12 @@ export class AimuxDaemon { return { status, body: `${message}\n`, contentType: "text/plain; charset=utf-8" }; } - private exposeItemsRoute(): DaemonRouteResponse { + private exposeItemsRoute(routeUrl: URL): DaemonRouteResponse { + const includePreview = routeUrl.searchParams.get("includePreview") === "1"; const items = listAllProjectsExposeItems().map((item) => { - const previewSnapshot = getExposePreviewSnapshot(item.projectRoot, item.target.windowId); + const previewSnapshot = includePreview + ? getExposePreviewSnapshot(item.projectRoot, item.target.windowId) + : undefined; return { ...serializeFastControlItem(previewSnapshot ? { ...item, previewSnapshot } : item), projectId: item.projectId, @@ -3356,7 +3359,7 @@ export class AimuxDaemon { if (method === "GET" && pathname === CORE_API_ROUTES.exposeItems) { if (actor) return { status: 403, body: { ok: false, error: "expose routes are loopback-only" } }; - return this.exposeItemsRoute(); + return this.exposeItemsRoute(routeUrl); } if (method === "POST" && pathname === CORE_API_ROUTES.exposeFocus) { diff --git a/src/expose-preview-cache.test.ts b/src/expose-preview-cache.test.ts index 273ee858..56e00b2e 100644 --- a/src/expose-preview-cache.test.ts +++ b/src/expose-preview-cache.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ExposePreviewCache, EXPOSE_PREVIEW_CAPTURE_LINES, getExposePreviewSnapshot } from "./expose-preview-cache.js"; import type { FastControlItem } from "./fast-control.js"; @@ -10,9 +10,13 @@ function item(id: string, windowId: string): Pick { - it("captures listed targets as preview snapshots", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("captures listed targets as preview snapshots", async () => { const tmux = { - captureTarget: vi.fn((target) => `output for ${target.windowId}\n`), + captureTargetAsync: vi.fn(async (target) => `output for ${target.windowId}\n`), }; const cache = new ExposePreviewCache({ projectRoot: "/repo", @@ -21,9 +25,15 @@ describe("ExposePreviewCache", () => { now: () => new Date("2026-07-20T13:00:00.000Z"), }); - cache.refreshNow(); + cache.start(); + try { + cache.trackItems([item("a", "@1")]); + await cache.refreshNow(); + } finally { + cache.stop(); + } - expect(tmux.captureTarget).toHaveBeenCalledWith(expect.objectContaining({ windowId: "@1" }), { + expect(tmux.captureTargetAsync).toHaveBeenCalledWith(expect.objectContaining({ windowId: "@1" }), { startLine: -EXPOSE_PREVIEW_CAPTURE_LINES, includeEscapes: true, }); @@ -37,9 +47,9 @@ describe("ExposePreviewCache", () => { }); }); - it("keeps the last good snapshot when capture fails", () => { + it("keeps the last good snapshot when capture fails", async () => { const tmux = { - captureTarget: vi.fn(() => "first output\n"), + captureTargetAsync: vi.fn(async () => "first output\n"), }; const cache = new ExposePreviewCache({ projectRoot: "/repo", @@ -48,26 +58,119 @@ describe("ExposePreviewCache", () => { now: () => new Date("2026-07-20T13:00:00.000Z"), }); - cache.refreshNow(); - tmux.captureTarget.mockImplementation(() => { - throw new Error("tmux unavailable"); - }); - cache.refreshNow(); + cache.start(); + try { + cache.trackItems([item("a", "@1")]); + await cache.refreshNow(); + tmux.captureTargetAsync.mockImplementation(async () => { + throw new Error("tmux unavailable"); + }); + await cache.refreshNow(); + } finally { + cache.stop(); + } expect(cache.get("@1")?.output).toBe("first output\n"); }); - it("registers running caches for daemon global expose responses", () => { + it("does not capture until demanded and stops scheduling after the active window", async () => { + vi.useFakeTimers(); + const tmux = { + captureTargetAsync: vi.fn(async (target) => `output for ${target.windowId}\n`), + }; + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux, + listItems: () => [item("a", "@1")], + intervalMs: 1000, + activeMs: 1500, + now: () => new Date(Date.now()), + }); + + cache.start(); + try { + vi.advanceTimersByTime(5000); + expect(tmux.captureTargetAsync).not.toHaveBeenCalled(); + + cache.trackItems([item("a", "@1")]); + await vi.runOnlyPendingTimersAsync(); + expect(tmux.captureTargetAsync).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1000); + expect(tmux.captureTargetAsync).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(1000); + expect(tmux.captureTargetAsync).toHaveBeenCalledTimes(2); + } finally { + cache.stop(); + } + }); + + it("prunes tracked targets and snapshots that are no longer listed", async () => { + let liveItems = [item("a", "@1")]; + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux: { captureTargetAsync: async () => "first output\n" }, + listItems: () => liveItems, + now: () => new Date("2026-07-20T13:00:00.000Z"), + }); + + cache.start(); + try { + cache.trackItems([item("a", "@1")]); + await cache.refreshNow(); + expect(cache.get("@1")?.output).toBe("first output\n"); + + liveItems = []; + await cache.refreshNow(); + expect(cache.get("@1")).toBeUndefined(); + } finally { + cache.stop(); + } + }); + + it("evicts tracked targets after repeated capture failures", async () => { + const tmux = { + captureTargetAsync: vi.fn(async () => { + throw new Error("tmux unavailable"); + }), + }; + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux, + listItems: () => { + throw new Error("list unavailable"); + }, + now: () => new Date("2026-07-20T13:00:00.000Z"), + }); + + cache.start(); + try { + cache.trackItems([item("a", "@1")]); + await cache.refreshNow(); + await cache.refreshNow(); + await cache.refreshNow(); + await cache.refreshNow(); + + expect(tmux.captureTargetAsync).toHaveBeenCalledTimes(3); + expect(cache.get("@1")).toBeUndefined(); + } finally { + cache.stop(); + } + }); + + it("registers running caches for daemon global expose responses", async () => { const cache = new ExposePreviewCache({ projectRoot: "/repo", - tmux: { captureTarget: () => "registered output\n" }, + tmux: { captureTargetAsync: async () => "registered output\n" }, listItems: () => [item("a", "@1")], now: () => new Date("2026-07-20T13:00:00.000Z"), }); cache.start(); try { - cache.refreshNow(); + cache.trackItems([item("a", "@1")]); + await cache.refreshNow(); expect(getExposePreviewSnapshot("/repo", "@1")?.output).toBe("registered output\n"); expect(getExposePreviewSnapshot("/repo/../repo", "@1")?.output).toBe("registered output\n"); } finally { diff --git a/src/expose-preview-cache.ts b/src/expose-preview-cache.ts index f43a4553..4cd44d4a 100644 --- a/src/expose-preview-cache.ts +++ b/src/expose-preview-cache.ts @@ -6,6 +6,8 @@ import type { TmuxTarget } from "./tmux/runtime-manager.js"; export const EXPOSE_PREVIEW_CAPTURE_LINES = 40; const EXPOSE_PREVIEW_REFRESH_MS = 1000; +const EXPOSE_PREVIEW_ACTIVE_MS = 10_000; +const EXPOSE_PREVIEW_MAX_CAPTURE_FAILURES = 3; type ExposePreviewTarget = Pick; @@ -18,9 +20,10 @@ export interface ExposePreviewCacheLike { export interface ExposePreviewCacheOptions { projectRoot: string; - tmux?: Pick; + tmux?: Pick; listItems: () => ExposePreviewTarget[]; intervalMs?: number; + activeMs?: number; lineCount?: number; now?: () => Date; } @@ -45,19 +48,23 @@ function unregisterExposePreviewCache(projectRoot: string, cache: ExposePreviewC } export class ExposePreviewCache implements ExposePreviewCacheLike { - private readonly tmux: Pick; + private readonly tmux: Pick; private readonly intervalMs: number; + private readonly activeMs: number; private readonly lineCount: number; private readonly now: () => Date; private readonly snapshots = new Map(); private readonly trackedTargets = new Map(); + private readonly failureCounts = new Map(); private timer: ReturnType | null = null; private running = false; private refreshing = false; + private activeUntil = 0; constructor(private readonly options: ExposePreviewCacheOptions) { this.tmux = options.tmux ?? new TmuxRuntimeManager(); this.intervalMs = options.intervalMs ?? EXPOSE_PREVIEW_REFRESH_MS; + this.activeMs = options.activeMs ?? EXPOSE_PREVIEW_ACTIVE_MS; this.lineCount = options.lineCount ?? EXPOSE_PREVIEW_CAPTURE_LINES; this.now = options.now ?? (() => new Date()); } @@ -66,7 +73,6 @@ export class ExposePreviewCache implements ExposePreviewCacheLike { if (this.running) return; this.running = true; registerExposePreviewCache(this.options.projectRoot, this); - this.schedule(0); } stop(): void { @@ -77,56 +83,77 @@ export class ExposePreviewCache implements ExposePreviewCacheLike { } trackItems(items: ExposePreviewTarget[]): void { + this.activeUntil = this.now().getTime() + this.activeMs; for (const item of items) { this.trackedTargets.set(item.target.windowId, item); } + if (items.length > 0) this.schedule(0); } get(windowId: string): ExposePreviewSnapshot | undefined { return this.snapshots.get(windowId); } - refreshNow(): void { - this.refresh(); + async refreshNow(): Promise { + await this.refresh(); } private schedule(delayMs = this.intervalMs): void { if (!this.running || this.timer) return; this.timer = setTimeout(() => { this.timer = null; - this.refresh(); + void this.refresh(); }, delayMs); this.timer.unref?.(); } - private listedTargets(): ExposePreviewTarget[] { + private listedTargets(): ExposePreviewTarget[] | null { try { return this.options.listItems(); } catch { - return []; + return null; } } - private refresh(): void { - if (this.refreshing) return; + private async refresh(): Promise { + if (!this.running || this.refreshing) return; + const now = this.now().getTime(); + if (now > this.activeUntil) return; this.refreshing = true; try { const targets = new Map(); - for (const item of this.listedTargets()) targets.set(item.target.windowId, item); + const listedTargets = this.listedTargets(); + if (listedTargets) { + const listedWindowIds = new Set(listedTargets.map((item) => item.target.windowId)); + for (const windowId of this.trackedTargets.keys()) { + if (!listedWindowIds.has(windowId)) { + this.trackedTargets.delete(windowId); + this.failureCounts.delete(windowId); + } + } + for (const windowId of this.snapshots.keys()) { + if (!listedWindowIds.has(windowId)) { + this.snapshots.delete(windowId); + this.failureCounts.delete(windowId); + } + } + for (const item of listedTargets) targets.set(item.target.windowId, item); + } for (const item of this.trackedTargets.values()) targets.set(item.target.windowId, item); - for (const item of targets.values()) this.capture(item.target); + for (const item of targets.values()) await this.capture(item.target); } finally { this.refreshing = false; - this.schedule(); + if (this.now().getTime() < this.activeUntil) this.schedule(); } } - private capture(target: TmuxTarget): void { + private async capture(target: TmuxTarget): Promise { try { - const output = this.tmux.captureTarget(target, { + const output = await this.tmux.captureTargetAsync(target, { startLine: -this.lineCount, includeEscapes: true, }); + this.failureCounts.delete(target.windowId); this.snapshots.set(target.windowId, { output, capturedAt: this.now().toISOString(), @@ -136,7 +163,13 @@ export class ExposePreviewCache implements ExposePreviewCacheLike { lineCount: this.lineCount, }); } catch { - return; + const failures = (this.failureCounts.get(target.windowId) ?? 0) + 1; + this.failureCounts.set(target.windowId, failures); + if (failures >= EXPOSE_PREVIEW_MAX_CAPTURE_FAILURES) { + this.trackedTargets.delete(target.windowId); + this.snapshots.delete(target.windowId); + this.failureCounts.delete(target.windowId); + } } } } diff --git a/src/metadata-server.test.ts b/src/metadata-server.test.ts index ac9a0cd3..67124bc5 100644 --- a/src/metadata-server.test.ts +++ b/src/metadata-server.test.ts @@ -2136,9 +2136,14 @@ describe("MetadataServer threads API", () => { try { const endpoint = server.getAddress(); expect(endpoint).toBeTruthy(); - const response = await fetch( - `http://${endpoint!.host}:${endpoint!.port}${PROJECT_API_ROUTES.controls.switchableAgents}?scope=all&labelFormat=raw`, - ); + const base = `http://${endpoint!.host}:${endpoint!.port}${PROJECT_API_ROUTES.controls.switchableAgents}`; + const withoutPreview = await fetch(`${base}?scope=all&labelFormat=raw`); + const withoutPreviewBody = (await withoutPreview.json()) as { ok: boolean; items: Array> }; + expect(withoutPreview.status).toBe(200); + expect(withoutPreviewBody.items[0]?.previewSnapshot).toBeUndefined(); + expect(exposePreviewCache.trackItems).not.toHaveBeenCalled(); + + const response = await fetch(`${base}?scope=all&labelFormat=raw&includePreview=1`); const body = (await response.json()) as { ok: boolean; items: Array> }; expect(response.status).toBe(200); diff --git a/src/metadata-server.ts b/src/metadata-server.ts index 7cf60fe6..53ec7f84 100644 --- a/src/metadata-server.ts +++ b/src/metadata-server.ts @@ -2631,6 +2631,7 @@ export class MetadataServer { const currentPath = url.searchParams.get("currentPath")?.trim() || undefined; const scope = url.searchParams.get("scope") === "all" ? "all" : "worktree"; const rawLabels = url.searchParams.get("labelFormat") === "raw"; + const includePreview = url.searchParams.get("includePreview") === "1"; const rawItems = listSwitchableAgentItems( { projectRoot: this.currentProjectRoot(), @@ -2642,9 +2643,9 @@ export class MetadataServer { new TmuxRuntimeManager(), { scope }, ); - this.exposePreviewCache?.trackItems(rawItems); + if (includePreview) this.exposePreviewCache?.trackItems(rawItems); const items = rawItems.map((item) => { - const previewSnapshot = this.exposePreviewCache?.get(item.target.windowId); + const previewSnapshot = includePreview ? this.exposePreviewCache?.get(item.target.windowId) : undefined; const serialized = serializeFastControlItem(previewSnapshot ? { ...item, previewSnapshot } : item); return { ...serialized, diff --git a/src/project-api-contract.ts b/src/project-api-contract.ts index 27113fc4..7b5b0121 100644 --- a/src/project-api-contract.ts +++ b/src/project-api-contract.ts @@ -1184,6 +1184,7 @@ export interface SwitchableAgentsInput { currentWindowId?: string; currentPath?: string; scope?: "all" | "worktree"; + includePreview?: "1"; } export type ExposePreviewSnapshotSource = "capture" | "tap"; diff --git a/src/tmux/expose-model.test.ts b/src/tmux/expose-model.test.ts index 9b0661bf..58937908 100644 --- a/src/tmux/expose-model.test.ts +++ b/src/tmux/expose-model.test.ts @@ -89,6 +89,7 @@ describe("loadExposeScopeItems", () => { expect(requested.pathname).toBe("/control/switchable-agents"); expect(requested.searchParams.get("scope")).toBe("worktree"); expect(requested.searchParams.get("labelFormat")).toBe("raw"); + expect(requested.searchParams.get("includePreview")).toBe("1"); expect(requested.searchParams.get("currentWindowId")).toBe("@2"); expect(view).toMatchObject({ scope: "worktree", scopeLabel: "this worktree", sublabel: "none" }); expect(view.items.map((i) => i.id)).toEqual(["wt-agent"]); @@ -134,6 +135,7 @@ describe("loadExposeScopeItems", () => { const view = await loadExposeScopeItems("project", context, createProjectStateDir(), { requestJsonFn }); const requested = new URL(requestJsonFn.mock.calls[0]![0]); expect(requested.searchParams.get("scope")).toBe("all"); + expect(requested.searchParams.get("includePreview")).toBe("1"); expect(view).toMatchObject({ scope: "project", scopeLabel: "all worktrees", sublabel: "worktree" }); expect(view.items.map((i) => i.id)).toEqual(["project-agent"]); }); @@ -150,6 +152,7 @@ describe("loadExposeScopeItems", () => { const requested = new URL(requestJsonFn.mock.calls[0]![0]); expect(requested.pathname).toBe("/core/expose/items"); expect(requested.searchParams.get("scope")).toBe(null); + expect(requested.searchParams.get("includePreview")).toBe("1"); expect(view).toMatchObject({ scope: "global", scopeLabel: "all projects", sublabel: "project-worktree" }); expect(view.items.map((i) => i.id)).toEqual(["global-agent"]); }); diff --git a/src/tmux/expose-model.ts b/src/tmux/expose-model.ts index 3610aa5d..a6971040 100644 --- a/src/tmux/expose-model.ts +++ b/src/tmux/expose-model.ts @@ -96,6 +96,7 @@ export async function loadExposeScopeItems( if (scope === "global") { const endpoint = deps.daemonEndpoint ?? getDaemonBaseUrl(); const url = new URL(CORE_API_ROUTES.exposeItems, endpoint.endsWith("/") ? endpoint : `${endpoint}/`); + url.searchParams.set("includePreview", "1"); const items = await requestExposeItems(url, deps); return { scope, @@ -109,6 +110,7 @@ export async function loadExposeScopeItems( const url = new URL(PROJECT_API_ROUTES.controls.switchableAgents, endpoint.endsWith("/") ? endpoint : `${endpoint}/`); url.searchParams.set("scope", scope === "worktree" ? "worktree" : "all"); url.searchParams.set("labelFormat", "raw"); + url.searchParams.set("includePreview", "1"); appendFocusContext(url, context); const items = await requestExposeItems(url, deps); return { diff --git a/src/tmux/runtime-manager.ts b/src/tmux/runtime-manager.ts index 1d2ed4a6..d040d9a7 100644 --- a/src/tmux/runtime-manager.ts +++ b/src/tmux/runtime-manager.ts @@ -914,6 +914,13 @@ export class TmuxRuntimeManager { return this.exec(args); } + async captureTargetAsync(target: TmuxTarget, options: CaptureTargetOptions = {}): Promise { + const startLine = options.startLine ?? "-"; + const args = ["capture-pane", "-p", "-J", "-t", target.windowId, "-S", String(startLine)]; + if (options.includeEscapes) args.splice(3, 0, "-e"); + return await this.execAsync(args); + } + resizeTarget(target: TmuxTarget, cols: number, rows: number): void { this.exec(["resize-window", "-t", target.windowId, "-x", String(cols), "-y", String(rows)]); } From c4dacb00012689ced651a999f6e021ccb61bd12c Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 20 Jul 2026 22:57:28 +0800 Subject: [PATCH 3/5] fix(expose): keep preview warming scope-bound --- src/expose-preview-cache.test.ts | 33 +++++++++++++++------------- src/expose-preview-cache.ts | 37 +++++++------------------------- src/metadata-server.ts | 4 ---- 3 files changed, 26 insertions(+), 48 deletions(-) diff --git a/src/expose-preview-cache.test.ts b/src/expose-preview-cache.test.ts index 56e00b2e..a2a09706 100644 --- a/src/expose-preview-cache.test.ts +++ b/src/expose-preview-cache.test.ts @@ -14,14 +14,13 @@ describe("ExposePreviewCache", () => { vi.useRealTimers(); }); - it("captures listed targets as preview snapshots", async () => { + it("captures tracked targets as preview snapshots", async () => { const tmux = { captureTargetAsync: vi.fn(async (target) => `output for ${target.windowId}\n`), }; const cache = new ExposePreviewCache({ projectRoot: "/repo", tmux, - listItems: () => [item("a", "@1"), item("b", "@2")], now: () => new Date("2026-07-20T13:00:00.000Z"), }); @@ -45,6 +44,10 @@ describe("ExposePreviewCache", () => { startLine: -EXPOSE_PREVIEW_CAPTURE_LINES, lineCount: EXPOSE_PREVIEW_CAPTURE_LINES, }); + expect(tmux.captureTargetAsync).not.toHaveBeenCalledWith(expect.objectContaining({ windowId: "@2" }), { + startLine: -EXPOSE_PREVIEW_CAPTURE_LINES, + includeEscapes: true, + }); }); it("keeps the last good snapshot when capture fails", async () => { @@ -54,7 +57,6 @@ describe("ExposePreviewCache", () => { const cache = new ExposePreviewCache({ projectRoot: "/repo", tmux, - listItems: () => [item("a", "@1")], now: () => new Date("2026-07-20T13:00:00.000Z"), }); @@ -81,7 +83,6 @@ describe("ExposePreviewCache", () => { const cache = new ExposePreviewCache({ projectRoot: "/repo", tmux, - listItems: () => [item("a", "@1")], intervalMs: 1000, activeMs: 1500, now: () => new Date(Date.now()), @@ -106,12 +107,13 @@ describe("ExposePreviewCache", () => { } }); - it("prunes tracked targets and snapshots that are no longer listed", async () => { - let liveItems = [item("a", "@1")]; + it("captures only the current demanded targets", async () => { + const tmux = { + captureTargetAsync: vi.fn(async (target) => `output for ${target.windowId}\n`), + }; const cache = new ExposePreviewCache({ projectRoot: "/repo", - tmux: { captureTargetAsync: async () => "first output\n" }, - listItems: () => liveItems, + tmux, now: () => new Date("2026-07-20T13:00:00.000Z"), }); @@ -119,14 +121,19 @@ describe("ExposePreviewCache", () => { try { cache.trackItems([item("a", "@1")]); await cache.refreshNow(); - expect(cache.get("@1")?.output).toBe("first output\n"); + tmux.captureTargetAsync.mockClear(); - liveItems = []; + cache.trackItems([item("b", "@2")]); await cache.refreshNow(); - expect(cache.get("@1")).toBeUndefined(); } finally { cache.stop(); } + + expect(tmux.captureTargetAsync).toHaveBeenCalledTimes(1); + expect(tmux.captureTargetAsync).toHaveBeenCalledWith(expect.objectContaining({ windowId: "@2" }), { + startLine: -EXPOSE_PREVIEW_CAPTURE_LINES, + includeEscapes: true, + }); }); it("evicts tracked targets after repeated capture failures", async () => { @@ -138,9 +145,6 @@ describe("ExposePreviewCache", () => { const cache = new ExposePreviewCache({ projectRoot: "/repo", tmux, - listItems: () => { - throw new Error("list unavailable"); - }, now: () => new Date("2026-07-20T13:00:00.000Z"), }); @@ -163,7 +167,6 @@ describe("ExposePreviewCache", () => { const cache = new ExposePreviewCache({ projectRoot: "/repo", tmux: { captureTargetAsync: async () => "registered output\n" }, - listItems: () => [item("a", "@1")], now: () => new Date("2026-07-20T13:00:00.000Z"), }); diff --git a/src/expose-preview-cache.ts b/src/expose-preview-cache.ts index 4cd44d4a..0c40123b 100644 --- a/src/expose-preview-cache.ts +++ b/src/expose-preview-cache.ts @@ -21,7 +21,6 @@ export interface ExposePreviewCacheLike { export interface ExposePreviewCacheOptions { projectRoot: string; tmux?: Pick; - listItems: () => ExposePreviewTarget[]; intervalMs?: number; activeMs?: number; lineCount?: number; @@ -84,6 +83,13 @@ export class ExposePreviewCache implements ExposePreviewCacheLike { trackItems(items: ExposePreviewTarget[]): void { this.activeUntil = this.now().getTime() + this.activeMs; + const requestedWindowIds = new Set(items.map((item) => item.target.windowId)); + for (const windowId of this.trackedTargets.keys()) { + if (!requestedWindowIds.has(windowId)) { + this.trackedTargets.delete(windowId); + this.failureCounts.delete(windowId); + } + } for (const item of items) { this.trackedTargets.set(item.target.windowId, item); } @@ -107,40 +113,13 @@ export class ExposePreviewCache implements ExposePreviewCacheLike { this.timer.unref?.(); } - private listedTargets(): ExposePreviewTarget[] | null { - try { - return this.options.listItems(); - } catch { - return null; - } - } - private async refresh(): Promise { if (!this.running || this.refreshing) return; const now = this.now().getTime(); if (now > this.activeUntil) return; this.refreshing = true; try { - const targets = new Map(); - const listedTargets = this.listedTargets(); - if (listedTargets) { - const listedWindowIds = new Set(listedTargets.map((item) => item.target.windowId)); - for (const windowId of this.trackedTargets.keys()) { - if (!listedWindowIds.has(windowId)) { - this.trackedTargets.delete(windowId); - this.failureCounts.delete(windowId); - } - } - for (const windowId of this.snapshots.keys()) { - if (!listedWindowIds.has(windowId)) { - this.snapshots.delete(windowId); - this.failureCounts.delete(windowId); - } - } - for (const item of listedTargets) targets.set(item.target.windowId, item); - } - for (const item of this.trackedTargets.values()) targets.set(item.target.windowId, item); - for (const item of targets.values()) await this.capture(item.target); + for (const item of this.trackedTargets.values()) await this.capture(item.target); } finally { this.refreshing = false; if (this.now().getTime() < this.activeUntil) this.schedule(); diff --git a/src/metadata-server.ts b/src/metadata-server.ts index 53ec7f84..e4c091b0 100644 --- a/src/metadata-server.ts +++ b/src/metadata-server.ts @@ -1373,10 +1373,6 @@ export class MetadataServer { const defaultExposePreviewCache = options.lifecycle?.readAgentOutput ? new ExposePreviewCache({ projectRoot: this.currentProjectRoot(), - listItems: () => - listSwitchableAgentItems({ projectRoot: this.currentProjectRoot() }, new TmuxRuntimeManager(), { - scope: "all", - }), }) : null; this.exposePreviewCache = From bb3d10890810a86969b8ef2db42a22c88b301990 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 20 Jul 2026 23:14:24 +0800 Subject: [PATCH 4/5] fix(expose): ignore stale preview captures --- src/expose-preview-cache.test.ts | 35 ++++++++++++++++++++++++++++++++ src/expose-preview-cache.ts | 10 ++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/expose-preview-cache.test.ts b/src/expose-preview-cache.test.ts index a2a09706..0e194c94 100644 --- a/src/expose-preview-cache.test.ts +++ b/src/expose-preview-cache.test.ts @@ -121,9 +121,11 @@ describe("ExposePreviewCache", () => { try { cache.trackItems([item("a", "@1")]); await cache.refreshNow(); + expect(cache.get("@1")?.output).toBe("output for @1\n"); tmux.captureTargetAsync.mockClear(); cache.trackItems([item("b", "@2")]); + expect(cache.get("@1")).toBeUndefined(); await cache.refreshNow(); } finally { cache.stop(); @@ -136,6 +138,39 @@ describe("ExposePreviewCache", () => { }); }); + it("ignores in-flight captures after demand changes", async () => { + let resolveCapture: ((output: string) => void) | undefined; + const tmux = { + captureTargetAsync: vi.fn( + () => + new Promise((resolve) => { + resolveCapture = resolve; + }), + ), + }; + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux, + now: () => new Date("2026-07-20T13:00:00.000Z"), + }); + + cache.start(); + try { + cache.trackItems([item("a", "@1")]); + const refresh = cache.refreshNow(); + expect(tmux.captureTargetAsync).toHaveBeenCalledTimes(1); + + cache.trackItems([item("b", "@2")]); + expect(cache.get("@1")).toBeUndefined(); + resolveCapture?.("late output\n"); + await refresh; + + expect(cache.get("@1")).toBeUndefined(); + } finally { + cache.stop(); + } + }); + it("evicts tracked targets after repeated capture failures", async () => { const tmux = { captureTargetAsync: vi.fn(async () => { diff --git a/src/expose-preview-cache.ts b/src/expose-preview-cache.ts index 0c40123b..0ae45f64 100644 --- a/src/expose-preview-cache.ts +++ b/src/expose-preview-cache.ts @@ -87,6 +87,7 @@ export class ExposePreviewCache implements ExposePreviewCacheLike { for (const windowId of this.trackedTargets.keys()) { if (!requestedWindowIds.has(windowId)) { this.trackedTargets.delete(windowId); + this.snapshots.delete(windowId); this.failureCounts.delete(windowId); } } @@ -119,7 +120,8 @@ export class ExposePreviewCache implements ExposePreviewCacheLike { if (now > this.activeUntil) return; this.refreshing = true; try { - for (const item of this.trackedTargets.values()) await this.capture(item.target); + const targets = [...this.trackedTargets.values()]; + for (const item of targets) await this.capture(item.target); } finally { this.refreshing = false; if (this.now().getTime() < this.activeUntil) this.schedule(); @@ -132,6 +134,7 @@ export class ExposePreviewCache implements ExposePreviewCacheLike { startLine: -this.lineCount, includeEscapes: true, }); + if (!this.isCurrentTarget(target)) return; this.failureCounts.delete(target.windowId); this.snapshots.set(target.windowId, { output, @@ -142,6 +145,7 @@ export class ExposePreviewCache implements ExposePreviewCacheLike { lineCount: this.lineCount, }); } catch { + if (!this.isCurrentTarget(target)) return; const failures = (this.failureCounts.get(target.windowId) ?? 0) + 1; this.failureCounts.set(target.windowId, failures); if (failures >= EXPOSE_PREVIEW_MAX_CAPTURE_FAILURES) { @@ -151,4 +155,8 @@ export class ExposePreviewCache implements ExposePreviewCacheLike { } } } + + private isCurrentTarget(target: TmuxTarget): boolean { + return this.trackedTargets.get(target.windowId)?.target === target; + } } From 156aa2de9374b2e053187cc3ac7bc99ab287421f Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 20 Jul 2026 23:33:05 +0800 Subject: [PATCH 5/5] fix(expose): stabilize preview cache demand --- src/daemon.ts | 16 ++++- src/expose-preview-cache.test.ts | 94 ++++++++++++++++++++++++--- src/expose-preview-cache.ts | 107 ++++++++++++++++++++++++------- 3 files changed, 185 insertions(+), 32 deletions(-) diff --git a/src/daemon.ts b/src/daemon.ts index 6a7ce0f3..612917cf 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -8,7 +8,7 @@ import { loadMetadataEndpointByProjectId, removeMetadataEndpoint } from "./metad import { requestJson } from "./http-client.js"; import { log } from "./debug.js"; import { listAllProjectsExposeItems } from "./expose-control.js"; -import { getExposePreviewSnapshot } from "./expose-preview-cache.js"; +import { getExposePreviewSnapshot, trackExposePreviewItems } from "./expose-preview-cache.js"; import { RelayClient, type RelayNotificationPush, type RelayStatusSnapshot } from "./relay-client.js"; import { MobilePushThrottle } from "./mobile-push-throttle.js"; import { clearCredentials, loadCredentials, setRemoteEnabled } from "./credentials.js"; @@ -666,7 +666,19 @@ export class AimuxDaemon { private exposeItemsRoute(routeUrl: URL): DaemonRouteResponse { const includePreview = routeUrl.searchParams.get("includePreview") === "1"; - const items = listAllProjectsExposeItems().map((item) => { + const rawItems = listAllProjectsExposeItems(); + if (includePreview) { + const itemsByProjectRoot = new Map(); + for (const item of rawItems) { + const projectItems = itemsByProjectRoot.get(item.projectRoot) ?? []; + projectItems.push(item); + itemsByProjectRoot.set(item.projectRoot, projectItems); + } + for (const [projectRoot, projectItems] of itemsByProjectRoot) { + trackExposePreviewItems(projectRoot, projectItems); + } + } + const items = rawItems.map((item) => { const previewSnapshot = includePreview ? getExposePreviewSnapshot(item.projectRoot, item.target.windowId) : undefined; diff --git a/src/expose-preview-cache.test.ts b/src/expose-preview-cache.test.ts index 0e194c94..3063876b 100644 --- a/src/expose-preview-cache.test.ts +++ b/src/expose-preview-cache.test.ts @@ -1,11 +1,20 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { ExposePreviewCache, EXPOSE_PREVIEW_CAPTURE_LINES, getExposePreviewSnapshot } from "./expose-preview-cache.js"; +import { + ExposePreviewCache, + EXPOSE_PREVIEW_CAPTURE_LINES, + getExposePreviewSnapshot, + trackExposePreviewItems, +} from "./expose-preview-cache.js"; import type { FastControlItem } from "./fast-control.js"; -function item(id: string, windowId: string): Pick { +function item( + id: string, + windowId: string, + target: Partial = {}, +): Pick { return { id, - target: { sessionName: "aimux-test", windowId, windowIndex: 1, windowName: "codex" }, + target: { sessionName: "aimux-test", windowId, windowIndex: 1, windowName: "codex", ...target }, }; } @@ -107,14 +116,16 @@ describe("ExposePreviewCache", () => { } }); - it("captures only the current demanded targets", async () => { + it("captures the active demand union until old demand expires", async () => { + let nowMs = Date.parse("2026-07-20T13:00:00.000Z"); const tmux = { captureTargetAsync: vi.fn(async (target) => `output for ${target.windowId}\n`), }; const cache = new ExposePreviewCache({ projectRoot: "/repo", tmux, - now: () => new Date("2026-07-20T13:00:00.000Z"), + activeMs: 1000, + now: () => new Date(nowMs), }); cache.start(); @@ -124,7 +135,20 @@ describe("ExposePreviewCache", () => { expect(cache.get("@1")?.output).toBe("output for @1\n"); tmux.captureTargetAsync.mockClear(); + nowMs += 500; cache.trackItems([item("b", "@2")]); + await cache.refreshNow(); + expect(tmux.captureTargetAsync).toHaveBeenCalledWith(expect.objectContaining({ windowId: "@1" }), { + startLine: -EXPOSE_PREVIEW_CAPTURE_LINES, + includeEscapes: true, + }); + expect(tmux.captureTargetAsync).toHaveBeenCalledWith(expect.objectContaining({ windowId: "@2" }), { + startLine: -EXPOSE_PREVIEW_CAPTURE_LINES, + includeEscapes: true, + }); + tmux.captureTargetAsync.mockClear(); + + nowMs += 600; expect(cache.get("@1")).toBeUndefined(); await cache.refreshNow(); } finally { @@ -138,7 +162,7 @@ describe("ExposePreviewCache", () => { }); }); - it("ignores in-flight captures after demand changes", async () => { + it("accepts in-flight captures after identical re-demand", async () => { let resolveCapture: ((output: string) => void) | undefined; const tmux = { captureTargetAsync: vi.fn( @@ -160,8 +184,42 @@ describe("ExposePreviewCache", () => { const refresh = cache.refreshNow(); expect(tmux.captureTargetAsync).toHaveBeenCalledTimes(1); - cache.trackItems([item("b", "@2")]); - expect(cache.get("@1")).toBeUndefined(); + cache.trackItems([item("a-fresh", "@1")]); + resolveCapture?.("late output\n"); + await refresh; + + expect(cache.get("@1")?.output).toBe("late output\n"); + } finally { + cache.stop(); + } + }); + + it("ignores in-flight captures after demand expires and is renewed", async () => { + let nowMs = Date.parse("2026-07-20T13:00:00.000Z"); + let resolveCapture: ((output: string) => void) | undefined; + const tmux = { + captureTargetAsync: vi.fn( + () => + new Promise((resolve) => { + resolveCapture = resolve; + }), + ), + }; + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux, + activeMs: 1000, + now: () => new Date(nowMs), + }); + + cache.start(); + try { + cache.trackItems([item("a", "@1")]); + const refresh = cache.refreshNow(); + expect(tmux.captureTargetAsync).toHaveBeenCalledTimes(1); + + nowMs += 1001; + cache.trackItems([item("a-renewed", "@1")]); resolveCapture?.("late output\n"); await refresh; @@ -216,4 +274,24 @@ describe("ExposePreviewCache", () => { } expect(getExposePreviewSnapshot("/repo", "@1")).toBeUndefined(); }); + + it("tracks demand through the project registry", async () => { + const tmux = { + captureTargetAsync: vi.fn(async (target) => `registry output for ${target.windowId}\n`), + }; + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux, + now: () => new Date("2026-07-20T13:00:00.000Z"), + }); + + cache.start(); + try { + trackExposePreviewItems("/repo/../repo", [item("a", "@1")]); + await cache.refreshNow(); + expect(cache.get("@1")?.output).toBe("registry output for @1\n"); + } finally { + cache.stop(); + } + }); }); diff --git a/src/expose-preview-cache.ts b/src/expose-preview-cache.ts index 0ae45f64..bfbdbd05 100644 --- a/src/expose-preview-cache.ts +++ b/src/expose-preview-cache.ts @@ -10,6 +10,7 @@ const EXPOSE_PREVIEW_ACTIVE_MS = 10_000; const EXPOSE_PREVIEW_MAX_CAPTURE_FAILURES = 3; type ExposePreviewTarget = Pick; +type TrackedExposePreviewTarget = ExposePreviewTarget & { expiresAt: number; generation: number }; export interface ExposePreviewCacheLike { start(): void; @@ -37,6 +38,10 @@ export function getExposePreviewSnapshot(projectRoot: string, windowId: string): return cachesByProjectRoot.get(normalizedProjectRoot(projectRoot))?.get(windowId); } +export function trackExposePreviewItems(projectRoot: string, items: ExposePreviewTarget[]): void { + cachesByProjectRoot.get(normalizedProjectRoot(projectRoot))?.trackItems(items); +} + function registerExposePreviewCache(projectRoot: string, cache: ExposePreviewCacheLike): void { cachesByProjectRoot.set(normalizedProjectRoot(projectRoot), cache); } @@ -53,12 +58,13 @@ export class ExposePreviewCache implements ExposePreviewCacheLike { private readonly lineCount: number; private readonly now: () => Date; private readonly snapshots = new Map(); - private readonly trackedTargets = new Map(); + private readonly trackedTargets = new Map(); private readonly failureCounts = new Map(); private timer: ReturnType | null = null; private running = false; private refreshing = false; - private activeUntil = 0; + private refreshPending = false; + private generation = 0; constructor(private readonly options: ExposePreviewCacheOptions) { this.tmux = options.tmux ?? new TmuxRuntimeManager(); @@ -82,22 +88,31 @@ export class ExposePreviewCache implements ExposePreviewCacheLike { } trackItems(items: ExposePreviewTarget[]): void { - this.activeUntil = this.now().getTime() + this.activeMs; - const requestedWindowIds = new Set(items.map((item) => item.target.windowId)); - for (const windowId of this.trackedTargets.keys()) { - if (!requestedWindowIds.has(windowId)) { - this.trackedTargets.delete(windowId); - this.snapshots.delete(windowId); - this.failureCounts.delete(windowId); - } - } + const now = this.now().getTime(); + this.pruneExpired(now); + const expiresAt = now + this.activeMs; + let changed = false; + const nextGeneration = this.generation + 1; for (const item of items) { - this.trackedTargets.set(item.target.windowId, item); + const current = this.trackedTargets.get(item.target.windowId); + if (current && sameTarget(current.target, item.target)) { + current.id = item.id; + current.expiresAt = expiresAt; + continue; + } + if (current) { + this.snapshots.delete(item.target.windowId); + this.failureCounts.delete(item.target.windowId); + } + this.trackedTargets.set(item.target.windowId, { ...item, expiresAt, generation: nextGeneration }); + changed = true; } + if (changed) this.generation += 1; if (items.length > 0) this.schedule(0); } get(windowId: string): ExposePreviewSnapshot | undefined { + this.pruneExpired(this.now().getTime()); return this.snapshots.get(windowId); } @@ -106,7 +121,16 @@ export class ExposePreviewCache implements ExposePreviewCacheLike { } private schedule(delayMs = this.intervalMs): void { - if (!this.running || this.timer) return; + if (!this.running) return; + if (this.refreshing) { + if (delayMs === 0) this.refreshPending = true; + return; + } + if (this.timer) { + if (delayMs !== 0) return; + clearTimeout(this.timer); + this.timer = null; + } this.timer = setTimeout(() => { this.timer = null; void this.refresh(); @@ -115,26 +139,43 @@ export class ExposePreviewCache implements ExposePreviewCacheLike { } private async refresh(): Promise { - if (!this.running || this.refreshing) return; const now = this.now().getTime(); - if (now > this.activeUntil) return; + if (!this.running) return; + if (this.refreshing) { + this.refreshPending = true; + return; + } + this.pruneExpired(now); + if (this.trackedTargets.size === 0) return; this.refreshing = true; + const refreshGeneration = this.generation; try { const targets = [...this.trackedTargets.values()]; - for (const item of targets) await this.capture(item.target); + for (const item of targets) { + if (this.generation !== refreshGeneration) break; + if (this.now().getTime() >= item.expiresAt || this.trackedTargets.get(item.target.windowId) !== item) continue; + await this.capture(item); + } } finally { this.refreshing = false; - if (this.now().getTime() < this.activeUntil) this.schedule(); + this.pruneExpired(this.now().getTime()); + if (this.refreshPending) { + this.refreshPending = false; + this.schedule(0); + } else if (this.trackedTargets.size > 0) { + this.schedule(); + } } } - private async capture(target: TmuxTarget): Promise { + private async capture(item: TrackedExposePreviewTarget): Promise { + const { target } = item; try { const output = await this.tmux.captureTargetAsync(target, { startLine: -this.lineCount, includeEscapes: true, }); - if (!this.isCurrentTarget(target)) return; + if (!this.isCurrentTarget(item)) return; this.failureCounts.delete(target.windowId); this.snapshots.set(target.windowId, { output, @@ -145,7 +186,7 @@ export class ExposePreviewCache implements ExposePreviewCacheLike { lineCount: this.lineCount, }); } catch { - if (!this.isCurrentTarget(target)) return; + if (!this.isCurrentTarget(item)) return; const failures = (this.failureCounts.get(target.windowId) ?? 0) + 1; this.failureCounts.set(target.windowId, failures); if (failures >= EXPOSE_PREVIEW_MAX_CAPTURE_FAILURES) { @@ -156,7 +197,29 @@ export class ExposePreviewCache implements ExposePreviewCacheLike { } } - private isCurrentTarget(target: TmuxTarget): boolean { - return this.trackedTargets.get(target.windowId)?.target === target; + private isCurrentTarget(item: TrackedExposePreviewTarget): boolean { + const current = this.trackedTargets.get(item.target.windowId); + return Boolean( + current && + current.generation === item.generation && + this.now().getTime() < current.expiresAt && + sameTarget(current.target, item.target), + ); } + + private pruneExpired(now: number): void { + let changed = false; + for (const [windowId, item] of this.trackedTargets) { + if (now < item.expiresAt) continue; + this.trackedTargets.delete(windowId); + this.snapshots.delete(windowId); + this.failureCounts.delete(windowId); + changed = true; + } + if (changed) this.generation += 1; + } +} + +function sameTarget(left: TmuxTarget, right: TmuxTarget): boolean { + return left.windowId === right.windowId; }