Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 9e4aab16cc fix(server): keep the daemon responsive during plugin builds (#7967)
* fix(server): keep plugin compilation off the daemon event loop

TypeScript import validation runs in esbuild callbacks on the daemon thread. Isolate preparation and compilation behind one bounded compiler queue, returning bundles and metadata only after runtime-boundary validation. Recheck lifecycle intent before executing a compiled plugin.

* fix(server): reuse the compiler worker during plugin build batches

Amortize TypeScript startup across built-ins and queued builds, then retire the worker after one idle second. Carry preparation environments explicitly across worker reuse. Keep dependency-backed example coverage with its existing app fixture.

* fix(server): retire the compiler worker after build errors

A failed parallel target can leave the other target compiling. Await worker termination before the next queued request instead of reusing an isolate with unfinished build work.

* fix(server): preserve compiler error metadata across workers

* test(server): isolate plugin validation in the loop-gap fixture

Exercise erased TypeScript syntax with a small runtime bundle so parser validation, not multi-MiB result transfer, determines the CI fixture. Keep the 20 ms gate and report event-loop active/idle time for scheduler diagnostics. Resolve the development loader at module initialization.

* test(server): probe event-loop turns during plugin compilation

Windows timer wake-up lateness measured 22 ms while the loop was idle for 21.96 ms. Use check-phase callbacks to measure loop availability, retaining the raw 20 ms limit and the failing base-branch reproduction.

* test: run plugin responsiveness probe in serial integration suite

* test: diagnose compiler probe gaps with per-thread CPU readings

* test: distinguish compiler CPU blocking from runner scheduling

* test: classify the largest compiler CPU accounting window

* test: isolate compiler responsiveness from harness GC and idle accounting

* test: measure callback blocking with a high-resolution clock
2026-10-27 10:30:41 +07:00
Mohamed Boudra 173fca3c0b Keep the daemon responsive during Claude history replay (#7940)
* fix(server): keep Claude history replay responsive

Load persisted history through session readiness rather than the constructor. Async descriptor-owned replay preserves ordering while serving other requests, and rebind retires the old query before rebuilding history and rewind state.

* fix(server): recover Claude history readiness after rebind errors

* test(server): calibrate Claude replay fixture for CI

* fix(server): report Claude rewind rebind failures to callers
2026-10-27 10:23:59 +07:00
Luke LaBonte 7f6a805e0e feat(app): choose the composer send shortcut (#5503)
Add a client-local Send shortcut selector with Enter and Command/Ctrl+Enter choices. Preserve Enter as the default and route the selected shortcut through the existing send behavior. Cover selection, persistence, editing, and running-agent send behavior with browser tests.

Co-authored-by: Luke LaBonte <contact@lukelabonte.com>
2026-10-27 10:05:16 +07:00
Mohamed Boudra 9bc998fa38 fix(server): keep Git observation and worktree IO off the event loop (#7943)
* fix(server): keep Git observation and worktree IO off the event loop

Resolve subscription path identities before callbacks and serialize async worktree metadata mutations by canonical ownership. Git command tracing must not probe the working directory before spawning.

* test(server): verify Git trace records without filesystem probes

* fix(server): establish Git identities at observation setup

Read-only checkout refreshes should not resolve watcher namespaces. Observation tests await setup and compare cross-workspace fan-out without depending on completion order.

* fix(server): retain literal symlink targets in Git event identity

Canonical realpaths can erase a symlink target spelling when its parent is also a symlink, such as /var on macOS. Resolve these aliases once during setup so deleted and ignored event paths still match without per-event IO.

* fix(server): preserve Windows worktree path spelling

* fix(server): retain native aliases in worktree ownership

* test(server): write Git IO trace markers asynchronously
2026-10-27 09:57:35 +07:00
Mohamed Boudra 67a2459254 fix(server): keep project config work off the daemon hot path (#7944)
Project settings and script health updates synchronously read unbounded user files. Share admitted async snapshots across consumers, budget script validation, and serialize revision-checked edits while keeping lexical project identity.
2026-10-27 09:56:51 +07:00
Mohamed Boudra f14a4cc112 fix(server): keep password-protected config reload responsive (#7959)
* fix(server): keep password-protected config reload responsive

Resolve authentication only during startup. Runtime config writes and reloads share an async queue so durable state and live owners stay ordered, including overlapping plugin changes and rollback.

* test(server): adapt config consumers to async service-port reads

Provide the service-port capability in the existing Session and tool-bridge fixtures. Use the platform temporary directory for the plugin agent fixture so its working directory exists on Windows.
2026-10-27 09:56:47 +07:00
Mohamed Boudra a25f4f5065 perf(server): serve timeline pages from retained projection (#7958)
* perf(server): select timeline pages from retained projection

Keep page selection and source coverage indexes with the retained projection so bounded fetches and last-message lookups do not copy or reproject history. Omitted after limits use the existing 200-item store default; explicit zero remains unlimited for released CLI clients.

* test(server): keep timeline page oracle outside production builds
2026-10-27 09:56:42 +07:00
Mohamed Boudra 6077bc6219 Keep daemon diff highlighting responsive and bounded (#7945)
* fix(server): keep diff highlighting responsive and bounded

Tiny patches previously parsed both full source versions synchronously. Yield between parser work units and admit source by UTF-8 bytes, keeping large patches renderable without tokens. Cancel obsolete highlighting when a newer diff supersedes it.

* fix(server): keep diff cancellation with its requester

Independent commit viewers must not cancel each other. Use request-source cancellation for commit highlighting and cache invalidation for checkout highlighting. Oversized source versions use the existing budgeted hunk reconstruction so large lockfiles retain useful syntax tokens.
2026-10-27 09:56:36 +07:00
Mohamed Boudra 9c7d4ace65 fix(server): reject oversized provider stdout before parsing (#7960)
Admit raw UTF-8 line bytes before accumulation and fail the provider session explicitly on overflow. Share line framing so fragmented JSONL input no longer repeatedly scans and copies the complete prefix.
2026-10-27 09:56:31 +07:00
Mohamed Boudra 8d971876bf fix(omp): keep transcript replay responsive (#7948)
* fix(omp): keep transcript replay responsive

Index transcript branches in bounded asynchronous chunks and retain payloads only for the active chain, so large histories do not block unrelated daemon requests.

* test(omp): enforce responsive history replay in CI

Use a smaller structured transcript and a short selected chain to expose whole-file parsing without making large-output GC part of the CI gate. Disable unrelated provider discovery and route the regression through the existing integration job.

* test(ci): include OMP replay in the routing contract
2026-10-27 09:56:27 +07:00
Mohamed Boudra fc0cbbb323 fix(server): keep workspace reconciliation responsive on slow paths (#7941)
* fix(server): keep workspace reconciliation responsive on slow paths

Bound asynchronous directory inspection without releasing timed-out filesystem slots. Preserve unavailable records and make workspace archival atomic and idempotent so concurrent user archival cannot be repeated by a scan.

* fix(server): wait for healthy reconciliation inspection capacity
2026-10-27 09:56:21 +07:00
david828019-ossandQTom 64b0afe010 fix(claude): identify SDK processes with the Paseo entrypoint (#7976)
Co-authored-by: QTom <qt@truesightai.com>
2026-10-27 09:38:49 +07:00
131 changed files with 7390 additions and 2324 deletions
@@ -1,3 +1,4 @@
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "../support/fixtures";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
@@ -274,3 +275,123 @@ test.describe("Settings toggle tab regression", () => {
}
});
});
async function selectSendingOption(
page: import("@playwright/test").Page,
setting: string,
option: string,
) {
await page.getByRole("button", { name: new RegExp(`^${setting}:`) }).click();
await page.getByRole("menuitem", { name: option, exact: true }).click();
await expect(
page.getByRole("button", { name: `${setting}: ${option}`, exact: true }),
).toBeVisible();
}
async function sendDraftWithShortcut(
page: import("@playwright/test").Page,
draft: string,
shortcut: string,
) {
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill(draft);
await composer.press(shortcut);
await expect(composer).toHaveValue("");
await expect(page.getByText(draft, { exact: true })).toBeVisible();
}
test("send shortcut selection persists and controls the composer", async ({ page }, testInfo) => {
test.setTimeout(180_000);
const workspace = await seedWorkspace({ repoPrefix: "send-shortcut-" });
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
try {
const agent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Send shortcut",
});
await openAgentRouteAndExpectFocused({
page,
serverId: getServerId(),
workspaceId: workspace.workspaceId,
agentId: agent.id,
});
await openComposerTab(page, agent.id);
const composer = page.getByRole("textbox", { name: "Message agent..." });
await test.step("Enter sends by default", async () => {
await sendDraftWithShortcut(page, "Default Enter submission", "Enter");
});
await test.step("choose the explicit modifier shortcut and retain it after reload", async () => {
await pressSettingsToggleShortcut(page);
await expect(
page.getByRole("button", { name: "Send shortcut: Enter", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Send shortcut: Enter", exact: true }).click();
await expect(page.getByRole("menuitem")).toHaveText(["Enter", `${modifierLabel}+Enter`]);
// Let the menu opening animation settle for visual QA.
await page.waitForTimeout(300);
await page.screenshot({ path: testInfo.outputPath("send-shortcut-selector.png") });
await page.getByRole("menuitem", { name: `${modifierLabel}+Enter`, exact: true }).click();
await page.reload();
await expect(
page.getByRole("button", { name: `Send shortcut: ${modifierLabel}+Enter`, exact: true }),
).toBeVisible();
await pressSettingsToggleShortcut(page);
await expect(composer).toBeVisible();
});
await test.step("Enter and Shift+Enter keep editing; modifier Enter sends", async () => {
await composer.fill("First line");
await composer.press("Enter");
await expect(composer).toHaveValue("First line\n");
await composer.press("Shift+Enter");
await expect(composer).toHaveValue("First line\n\n");
await sendDraftWithShortcut(page, "Sent with Control", "Control+Enter");
await sendDraftWithShortcut(page, "Sent with Command", "Meta+Enter");
});
await test.step("switch back to Enter without changing the default send behavior", async () => {
await pressSettingsToggleShortcut(page);
await selectSendingOption(page, "Send shortcut", "Enter");
await pressSettingsToggleShortcut(page);
await composer.fill("Shift still edits");
await composer.press("Shift+Enter");
await expect(composer).toHaveValue("Shift still edits\n");
await sendDraftWithShortcut(page, "Restored Enter submission", "Enter");
});
} finally {
await workspace.cleanup();
}
});
for (const behavior of ["Steer", "Interrupt", "Queue"] as const) {
test(`modifier send shortcut follows ${behavior} while an agent runs`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "running-send-shortcut-",
title: "Running send shortcut",
model: "one-minute-stream",
});
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
try {
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await pressSettingsToggleShortcut(page);
await selectSendingOption(page, "Send shortcut", `${modifierLabel}+Enter`);
await selectSendingOption(page, "Default send", behavior);
await pressSettingsToggleShortcut(page);
await sendDraftWithShortcut(page, "Stay running while I write a follow-up", "Control+Enter");
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill("Follow-up");
await composer.press("Enter");
await expect(composer).toHaveValue("Follow-up\n");
await composer.press("Control+Enter");
await expect(composer).toHaveValue("");
await expect(page.getByText("Follow-up", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Send queued message" })).toHaveCount(
behavior === "Queue" ? 1 : 0,
);
} finally {
await agent.cleanup();
}
});
}
+1
View File
@@ -2495,6 +2495,7 @@ function ComposerContentImpl({
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={activeSendBehavior}
sendShortcut={appSettings.sendShortcut}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
+34 -4
View File
@@ -1,3 +1,4 @@
import type { SendShortcut } from "@/hooks/use-settings";
import {
useComposerEditingSession,
useComposerFullscreen,
@@ -88,6 +89,7 @@ import {
resolveAlternateSendAction,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveMessageInputEnterAction,
resolveRetainedSelection,
runAlternateSendAction,
runDefaultSendAction,
@@ -96,6 +98,11 @@ import {
} from "./state";
const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]];
const MOD_ENTER_SEND_KEYS: ShortcutKey[][] = [["mod", "Enter"]];
function resolveSendKeys(sendShortcut: SendShortcut): ShortcutKey[][] {
return sendShortcut === "mod-enter" ? MOD_ENTER_SEND_KEYS : DEFAULT_SEND_KEYS;
}
const COMPOSER_INPUT_DATASET = { composerInput: "" } as const;
export interface AttachmentMenuItem {
@@ -167,6 +174,7 @@ export interface MessageInputProps {
* running. "interrupt" and "steer" send immediately, "queue" queues. Required so the default
* lives only in DEFAULT_CLIENT_SETTINGS. */
defaultSendBehavior: "interrupt" | "steer" | "queue";
sendShortcut: SendShortcut;
/** Callback for queue button when agent is running */
onQueue?: (payload: MessagePayload) => void;
/** Optional handler used when submit button is in loading state. */
@@ -402,6 +410,9 @@ interface DesktopKeyPressContext {
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
input: ComposerKeyPressEvent["input"];
submitOnEnter: boolean;
sendShortcut: SendShortcut;
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
isSubmitDisabled: boolean;
isSubmitLoading: boolean;
disabled: boolean;
@@ -428,12 +439,21 @@ function handleDesktopKeyPressImpl(
}
if (event.nativeEvent.key !== "Enter") return;
if (!ctx.submitOnEnter) return;
if (shiftKey) return;
const action = resolveMessageInputEnterAction({
submitOnEnter: ctx.submitOnEnter,
sendShortcut: ctx.sendShortcut,
shiftKey: shiftKey ?? false,
metaKey: metaKey ?? false,
ctrlKey: ctrlKey ?? false,
isAgentRunning: ctx.isAgentRunning,
onQueue: ctx.onQueue,
});
if (action === null) return;
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
event.preventDefault();
if (metaKey || ctrlKey) {
if (action === "alternate-send") {
ctx.handleAlternateSendAction();
return;
}
@@ -1112,6 +1132,7 @@ interface ResolvedMessageInputProps {
voiceAgentId: string | undefined;
isAgentRunning: boolean;
defaultSendBehavior: "interrupt" | "steer" | "queue";
sendShortcut: SendShortcut;
onQueue: ((payload: MessagePayload) => void) | undefined;
onSubmitLoadingPress: (() => void) | undefined;
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
@@ -1159,6 +1180,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
voiceAgentId: props.voiceAgentId,
isAgentRunning: props.isAgentRunning ?? false,
defaultSendBehavior: props.defaultSendBehavior,
sendShortcut: props.sendShortcut,
onQueue: props.onQueue,
onSubmitLoadingPress: props.onSubmitLoadingPress,
onKeyPressCallback: props.onKeyPress,
@@ -1236,6 +1258,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
voiceAgentId,
isAgentRunning,
defaultSendBehavior,
sendShortcut,
onQueue,
onSubmitLoadingPress,
onKeyPressCallback,
@@ -1755,6 +1778,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
selectionRef.current,
),
submitOnEnter: shouldSubmitOnEnter,
sendShortcut,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
@@ -1766,6 +1792,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
shouldHandleWebKeyPress,
onKeyPressCallback,
shouldSubmitOnEnter,
sendShortcut,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
@@ -1980,7 +2009,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
submitLabel={submitLabel}
submitButtonTestID={submitButtonTestID}
buttonIconSize={buttonIconSize}
sendKeys={DEFAULT_SEND_KEYS}
sendKeys={resolveSendKeys(sendShortcut)}
sendTooltipLabel={sendTooltipLabel}
/>
),
@@ -2001,6 +2030,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
submitLabel,
submitButtonTestID,
buttonIconSize,
sendShortcut,
sendTooltipLabel,
],
);
@@ -2,10 +2,12 @@ import { describe, expect, it, vi } from "vitest";
import {
applyDictationTranscript,
computeCanStartDictation,
type MessageInputEnterContext,
resolveActiveSendBehavior,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveGlyphRailOffset,
resolveMessageInputEnterAction,
resolveRetainedSelection,
resolveAlternateSendAction,
runAlternateSendAction,
@@ -320,6 +322,212 @@ describe("composer send behavior", () => {
});
});
describe("resolveMessageInputEnterAction", () => {
function enterContext(
overrides: Partial<MessageInputEnterContext> = {},
): MessageInputEnterContext {
return {
submitOnEnter: true,
sendShortcut: "enter",
shiftKey: false,
metaKey: false,
ctrlKey: false,
isAgentRunning: false,
onQueue: () => undefined,
...overrides,
};
}
it("returns null for plain, Shift, and modifier Enter when submit on enter is disabled", () => {
expect(resolveMessageInputEnterAction(enterContext({ submitOnEnter: false }))).toBeNull();
expect(
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, shiftKey: true })),
).toBeNull();
expect(
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, metaKey: true })),
).toBeNull();
expect(
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, ctrlKey: true })),
).toBeNull();
});
it("still returns null when modifier Enter sends and submit on enter is disabled", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", submitOnEnter: false, metaKey: true }),
),
).toBeNull();
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", submitOnEnter: false }),
),
).toBeNull();
});
it("sends on plain Enter when Enter sends", () => {
expect(resolveMessageInputEnterAction(enterContext())).toBe("default-send");
});
it("returns null for Shift+Enter when Enter sends", () => {
expect(resolveMessageInputEnterAction(enterContext({ shiftKey: true }))).toBeNull();
});
it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter so Shift keeps its newline", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ shiftKey: true, metaKey: true, isAgentRunning: true }),
),
).toBeNull();
expect(
resolveMessageInputEnterAction(
enterContext({ shiftKey: true, ctrlKey: true, isAgentRunning: true }),
),
).toBeNull();
});
it("sends on Cmd+Enter and Ctrl+Enter while idle even with a queue handler", () => {
expect(resolveMessageInputEnterAction(enterContext({ metaKey: true }))).toBe("default-send");
expect(resolveMessageInputEnterAction(enterContext({ ctrlKey: true }))).toBe("default-send");
});
it("queues on Cmd+Enter and Ctrl+Enter while the agent is running with a queue handler", () => {
expect(
resolveMessageInputEnterAction(enterContext({ metaKey: true, isAgentRunning: true })),
).toBe("alternate-send");
expect(
resolveMessageInputEnterAction(enterContext({ ctrlKey: true, isAgentRunning: true })),
).toBe("alternate-send");
});
it("sends on Cmd+Enter and Ctrl+Enter while running when no queue handler exists", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ metaKey: true, isAgentRunning: true, onQueue: undefined }),
),
).toBe("default-send");
expect(
resolveMessageInputEnterAction(
enterContext({ ctrlKey: true, isAgentRunning: true, onQueue: undefined }),
),
).toBe("default-send");
});
it("returns null for plain and Shift+Enter when modifier Enter sends", () => {
expect(resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter" }))).toBeNull();
expect(
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", shiftKey: true })),
).toBeNull();
});
it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter when modifier Enter sends", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", shiftKey: true, metaKey: true }),
),
).toBeNull();
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", shiftKey: true, ctrlKey: true }),
),
).toBeNull();
});
it("sends on Cmd+Enter and Ctrl+Enter while idle when modifier Enter sends", () => {
expect(
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", metaKey: true })),
).toBe("default-send");
expect(
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", ctrlKey: true })),
).toBe("default-send");
});
it("uses the default action on modifier Enter while running when modifier Enter sends", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }),
),
).toBe("default-send");
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", ctrlKey: true, isAgentRunning: true }),
),
).toBe("default-send");
});
describe("composed with the send actions", () => {
function actions() {
const calls: string[] = [];
return {
calls,
handleSendMessage: () => calls.push("send"),
handleQueueMessage: () => calls.push("queue"),
onQueue: () => undefined,
};
}
function resolveCommandEnterToSendModifierEnter() {
return resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }),
);
}
it("queues through the default send action when queue behavior is selected and the agent is running", () => {
const sendAction = actions();
expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send");
runDefaultSendAction({
defaultSendBehavior: "queue",
isAgentRunning: true,
onQueue: sendAction.onQueue,
handleSendMessage: sendAction.handleSendMessage,
handleQueueMessage: sendAction.handleQueueMessage,
});
expect(sendAction.calls).toEqual(["queue"]);
});
it("sends through the default send action when queue behavior is selected and the agent is idle", () => {
const sendAction = actions();
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: false }),
),
).toBe("default-send");
runDefaultSendAction({
defaultSendBehavior: "queue",
isAgentRunning: false,
onQueue: sendAction.onQueue,
handleSendMessage: sendAction.handleSendMessage,
handleQueueMessage: sendAction.handleQueueMessage,
});
expect(sendAction.calls).toEqual(["send"]);
});
it.each([{ defaultSendBehavior: "steer" }, { defaultSendBehavior: "interrupt" }] as const)(
"sends through the default send action when $defaultSendBehavior behavior is selected and the agent is running",
({ defaultSendBehavior }) => {
const sendAction = actions();
expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send");
runDefaultSendAction({
defaultSendBehavior,
isAgentRunning: true,
onQueue: sendAction.onQueue,
handleSendMessage: sendAction.handleSendMessage,
handleQueueMessage: sendAction.handleQueueMessage,
});
expect(sendAction.calls).toEqual(["send"]);
},
);
});
});
describe("stopRealtimeVoice", () => {
it("keeps voice mode active when the running agent refuses cancellation", async () => {
const cancellationError = new Error("active run cancellation was not acknowledged");
+24
View File
@@ -1,3 +1,4 @@
import type { SendShortcut } from "@/hooks/use-settings";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import type { MessagePayload, TextReplacement } from "@/composer/types";
@@ -151,6 +152,29 @@ export function runAlternateSendAction(ctx: SendActionContext): void {
runDefaultSendAction(ctx);
}
export type MessageInputEnterAction = "alternate-send" | "default-send";
export interface MessageInputEnterContext {
submitOnEnter: boolean;
sendShortcut: SendShortcut;
shiftKey: boolean;
metaKey: boolean;
ctrlKey: boolean;
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
}
export function resolveMessageInputEnterAction(
ctx: MessageInputEnterContext,
): MessageInputEnterAction | null {
if (!ctx.submitOnEnter) return null;
if (ctx.shiftKey) return null;
const hasModifier = ctx.metaKey || ctx.ctrlKey;
if (ctx.sendShortcut === "mod-enter") return hasModifier ? "default-send" : null;
if (hasModifier && ctx.isAgentRunning && ctx.onQueue) return "alternate-send";
return "default-send";
}
export function runMessageInputKeyboardAction(
action: MessageInputKeyboardActionKind,
actions: MessageInputKeyboardActions,
@@ -49,6 +49,7 @@ import {
type KeyValueStorage,
type ReleaseChannel,
type SendBehavior,
type SendShortcut,
type ServiceUrlBehavior,
type Settings,
type SidebarWorkspaceTrailing,
@@ -93,6 +94,7 @@ export type {
KeyValueStorage,
ReleaseChannel,
SendBehavior,
SendShortcut,
ServiceUrlBehavior,
Settings,
SettingsDeps,
@@ -227,6 +227,39 @@ describe("loadAppSettingsFromStorage", () => {
expect(result.chatOutlineEnabled).toBe(false);
});
it("uses Enter to send by default", async () => {
const deps = makeDeps();
const result = await loadAppSettingsFromStorage(deps);
expect(result.sendShortcut).toBe("enter");
expect(DEFAULT_CLIENT_SETTINGS.sendShortcut).toBe("enter");
});
it("loads the modifier Enter send preference", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "mod-enter" }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.sendShortcut).toBe("mod-enter");
});
it("falls back to Enter when the stored send shortcut is invalid", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "yes" }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.sendShortcut).toBe("enter");
});
it("defaults sidebar navigation items to an empty preference list", async () => {
const deps = makeDeps();
@@ -33,6 +33,8 @@ import { migrateAppSettings } from "./migrations";
export { APP_SETTINGS_KEY } from "./keys";
export const APP_SETTINGS_QUERY_KEY = ["app-settings"];
export type SendShortcut = "enter" | "mod-enter";
export type SendBehavior = ActiveTurnBehavior | "queue";
export type ReleaseChannel = "stable" | "beta";
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
@@ -104,6 +106,7 @@ export interface AppSettings {
chatDisplay: ChatDisplay;
chatOutlineEnabled: boolean;
vimKeybindings: boolean;
sendShortcut: SendShortcut;
filePanelAssociations: Record<string, string>;
/** Desktop-only preferences for implicit opens into the ordinary side pane. */
openInSidePane: OpenInSidePanePreferences;
@@ -160,6 +163,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
chatDisplay: "collapseTurns",
chatOutlineEnabled: true,
vimKeybindings: false,
sendShortcut: "enter",
filePanelAssociations: {},
openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES,
pullRequestOpenLocation: "explorer",
@@ -264,6 +268,7 @@ const StoredAppSettingsSchema = z
compactToolCalls: z.boolean().optional().catch(undefined),
chatOutlineEnabled: z.boolean().catch(true),
vimKeybindings: z.boolean().catch(false),
sendShortcut: z.enum(["enter", "mod-enter"]).catch("enter"),
filePanelAssociations: z.record(z.string(), z.string()).catch({}),
openInSidePane: z
.object({
+13
View File
@@ -2369,12 +2369,25 @@ export const ar: TranslationResources = {
"عند تشغيل الوكيل، يوجّه Enter الجولة النشطة. يضيف Command/Ctrl+Enter إلى قائمة الانتظار.",
queue: "عند تشغيل الوكيل، يضيف Enter إلى قائمة الانتظار. Command/Ctrl+Enter يرسل.",
},
commandEnterDescriptions: {
interrupt: "عند تشغيل الوكيل، يوقف {{modifier}}+Enter التشغيل. يُدرج Enter سطرًا جديدًا.",
steer: "عند تشغيل الوكيل، يوجّه {{modifier}}+Enter الجولة النشطة. يُدرج Enter سطرًا جديدًا.",
queue:
"عند تشغيل الوكيل، يضيف {{modifier}}+Enter إلى قائمة الانتظار. يُدرج Enter سطرًا جديدًا.",
},
options: {
interrupt: "مقاطعة",
steer: "توجيه",
queue: "طابور",
},
},
sendShortcut: {
label: "اختصار الإرسال",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "بسأل",
+14
View File
@@ -2457,12 +2457,26 @@ export const en = {
"When the agent is running, Enter steers the active turn. Command/Ctrl+Enter queues.",
queue: "When the agent is running, Enter queues. Command/Ctrl+Enter submits.",
},
commandEnterDescriptions: {
interrupt:
"When the agent is running, {{modifier}}+Enter interrupts. Enter inserts a new line.",
steer:
"When the agent is running, {{modifier}}+Enter steers the active turn. Enter inserts a new line.",
queue: "When the agent is running, {{modifier}}+Enter queues. Enter inserts a new line.",
},
options: {
interrupt: "Interrupt",
steer: "Steer",
queue: "Queue",
},
},
sendShortcut: {
label: "Send shortcut",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Ask",
+15
View File
@@ -2384,12 +2384,27 @@ export const es: TranslationResources = {
queue:
"Cuando el agente se está ejecutando, Enter pone en cola. Command/Ctrl+Enter envía.",
},
commandEnterDescriptions: {
interrupt:
"Cuando el agente se está ejecutando, {{modifier}}+Enter interrumpe. Enter inserta una línea nueva.",
steer:
"Cuando el agente se está ejecutando, {{modifier}}+Enter dirige el turno activo. Enter inserta una línea nueva.",
queue:
"Cuando el agente se está ejecutando, {{modifier}}+Enter pone en cola. Enter inserta una línea nueva.",
},
options: {
interrupt: "Interrumpir",
steer: "Dirigir",
queue: "Cola",
},
},
sendShortcut: {
label: "Atajo de envío",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Preguntar",
+15
View File
@@ -2383,12 +2383,27 @@ export const fr: TranslationResources = {
queue:
"Quand l’agent est en cours d’exécution, Entrée met en file d’attente. Cmd/Ctrl+Entrée envoie.",
},
commandEnterDescriptions: {
interrupt:
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée interrompt. Entrée insère une nouvelle ligne.",
steer:
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée oriente le tour actif. Entrée insère une nouvelle ligne.",
queue:
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée met en file d'attente. Entrée insère une nouvelle ligne.",
},
options: {
interrupt: "Interrompre",
steer: "Réorienter",
queue: "File d’attente",
},
},
sendShortcut: {
label: "Raccourci d’envoi",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Demander",
+13
View File
@@ -2353,12 +2353,25 @@ export const ja: TranslationResources = {
"エージェント実行中、Enterで現在のターンに指示を送ります。Command/Ctrl+Enterでキューに追加。",
queue: "エージェント実行中、Enterでキューに追加します。Command/Ctrl+Enterで送信。",
},
commandEnterDescriptions: {
interrupt: "エージェント実行中、{{modifier}}+Enterで中断します。Enterで改行します。",
steer:
"エージェント実行中、{{modifier}}+Enterで現在のターンに指示を送ります。Enterで改行します。",
queue: "エージェント実行中、{{modifier}}+Enterでキューに追加します。Enterで改行します。",
},
options: {
interrupt: "中断",
steer: "指示",
queue: "キュー",
},
},
sendShortcut: {
label: "送信ショートカット",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "確認する",
+15
View File
@@ -2344,12 +2344,27 @@ export const ko: TranslationResources = {
queue:
"에이전트가 실행 중일 때 Enter는 대기열에 추가합니다. Command/Ctrl+Enter는 제출합니다.",
},
commandEnterDescriptions: {
interrupt:
"에이전트가 실행 중일 때 {{modifier}}+Enter는 중단합니다. Enter는 줄바꿈합니다.",
steer:
"에이전트가 실행 중일 때 {{modifier}}+Enter는 현재 턴에 지시합니다. Enter는 줄바꿈합니다.",
queue:
"에이전트가 실행 중일 때 {{modifier}}+Enter는 대기열에 추가합니다. Enter는 줄바꿈합니다.",
},
options: {
interrupt: "중단",
steer: "지시",
queue: "대기열",
},
},
sendShortcut: {
label: "전송 단축키",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "물어보기",
+15
View File
@@ -2367,12 +2367,27 @@ export const ptBR: TranslationResources = {
"Quando o agente está em execução, Enter orienta o turno ativo. Command/Ctrl+Enter enfileira.",
queue: "Quando o agente está em execução, Enter enfileira. Command/Ctrl+Enter envia.",
},
commandEnterDescriptions: {
interrupt:
"Quando o agente está em execução, {{modifier}}+Enter interrompe. Enter insere uma nova linha.",
steer:
"Quando o agente está em execução, {{modifier}}+Enter orienta o turno ativo. Enter insere uma nova linha.",
queue:
"Quando o agente está em execução, {{modifier}}+Enter enfileira. Enter insere uma nova linha.",
},
options: {
interrupt: "Interromper",
steer: "Orientar",
queue: "Fila",
},
},
sendShortcut: {
label: "Atalho de envio",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Perguntar",
+15
View File
@@ -2370,12 +2370,27 @@ export const ru: TranslationResources = {
queue:
"Когда агент работает, Enter ставит сообщение в очередь, а Command/Ctrl+Enter отправляет его.",
},
commandEnterDescriptions: {
interrupt:
"Когда агент работает, {{modifier}}+Enter прерывает его работу, а Enter вставляет новую строку.",
steer:
"Когда агент работает, {{modifier}}+Enter дополняет текущий ответ новым указанием, а Enter вставляет новую строку.",
queue:
"Когда агент работает, {{modifier}}+Enter ставит сообщение в очередь, а Enter вставляет новую строку.",
},
options: {
interrupt: "Прерывать",
steer: "Направить",
queue: "Поставить в очередь",
},
},
sendShortcut: {
label: "Сочетание клавиш для отправки",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Спрашивать",
+12
View File
@@ -2308,12 +2308,24 @@ export const zhCN: TranslationResources = {
steer: "Agent 运行时,Enter 会引导当前回合。Command/Ctrl+Enter 会排队。",
queue: "Agent 运行时,Enter 会排队。Command/Ctrl+Enter 会提交。",
},
commandEnterDescriptions: {
interrupt: "Agent 运行时,{{modifier}}+Enter 会中断。Enter 会插入换行。",
steer: "Agent 运行时,{{modifier}}+Enter 会引导当前回合。Enter 会插入换行。",
queue: "Agent 运行时,{{modifier}}+Enter 会排队。Enter 会插入换行。",
},
options: {
interrupt: "中断",
steer: "引导",
queue: "排队",
},
},
sendShortcut: {
label: "发送快捷键",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "询问",
@@ -1,13 +1,17 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { SettingsCard, SettingsSection, SettingsSelect } from "@/components/settings";
import { useAppSettings, type SendBehavior } from "@/hooks/use-settings";
import { useAppSettings, type SendShortcut, type SendBehavior } from "@/hooks/use-settings";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { getShortcutOs } from "@/utils/shortcut-platform";
const SEND_BEHAVIORS: readonly SendBehavior[] = ["interrupt", "steer", "queue"];
export function SendingSection() {
const { t } = useTranslation();
const { settings, updateSettings } = useAppSettings();
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const modifier = getShortcutOs() === "mac" ? "⌘" : "Ctrl";
const options = useMemo(
() =>
SEND_BEHAVIORS.map((value) => ({
@@ -20,12 +24,37 @@ export function SendingSection() {
(sendBehavior: SendBehavior) => void updateSettings({ sendBehavior }),
[updateSettings],
);
const shortcutOptions = useMemo(
() =>
(["enter", "mod-enter"] as const).map((value) => ({
value,
label: t(`settings.general.sendShortcut.options.${value}`, { modifier }),
})),
[t, modifier],
);
const changeSendShortcut = useCallback(
(sendShortcut: SendShortcut) => void updateSettings({ sendShortcut }),
[updateSettings],
);
return (
<SettingsSection title={t("settings.general.sending")}>
<SettingsCard>
{shortcutsAvailable ? (
<SettingsSelect
label={t("settings.general.sendShortcut.label")}
value={settings.sendShortcut}
options={shortcutOptions}
onValueChange={changeSendShortcut}
/>
) : null}
<SettingsSelect
label={t("settings.general.defaultSend.label")}
hint={t(`settings.general.defaultSend.descriptions.${settings.sendBehavior}`)}
hint={t(
shortcutsAvailable && settings.sendShortcut === "mod-enter"
? `settings.general.defaultSend.commandEnterDescriptions.${settings.sendBehavior}`
: `settings.general.defaultSend.descriptions.${settings.sendBehavior}`,
{ modifier },
)}
value={settings.sendBehavior}
options={options}
onValueChange={change}
@@ -1,4 +1,6 @@
import { describe, it, expect } from "vitest";
import { setImmediate } from "node:timers/promises";
import { highlightCodeWithBudget, HIGHLIGHT_MAX_SOURCE_BYTES } from "../budgeted-highlighter.js";
import type { HighlightToken } from "../types.js";
import { highlightCode, highlightCodeInSteps, highlightLine } from "../highlighter.js";
@@ -420,3 +422,55 @@ describe("highlightLine", () => {
expect(tokens).toEqual([{ text: "hello", style: null }]);
});
});
describe("highlightCodeWithBudget", () => {
it("yields to IO while producing byte-identical output below the source budget", async () => {
const code = "const value = Math.max(42, 7);\n".repeat(3_000);
const expected = JSON.stringify(highlightCode(code, "file.ts"));
let ticks = 0;
let longestGap = 0;
let lastTick = performance.now();
const timer = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - lastTick);
lastTick = now;
ticks++;
}, 1);
try {
const actual = await highlightCodeWithBudget(code, "file.ts", {
yieldToEventLoop: setImmediate,
});
await new Promise((resolve) => setTimeout(resolve, 5));
expect(ticks).toBeGreaterThan(1);
expect(longestGap).toBeLessThan(50);
expect(JSON.stringify(actual)).toBe(expected);
} finally {
clearInterval(timer);
}
});
it("budgets UTF-8 bytes before parsing, including multibyte text", async () => {
expect(
await highlightCodeWithBudget("é".repeat(HIGHLIGHT_MAX_SOURCE_BYTES / 2 + 1), "file.ts", {
yieldToEventLoop: setImmediate,
}),
).toBeNull();
expect(
await highlightCodeWithBudget("a".repeat(HIGHLIGHT_MAX_SOURCE_BYTES + 1), "file.ts", {
yieldToEventLoop: setImmediate,
}),
).toBeNull();
});
it("stops a parser that is cancelled between real event-loop turns", async () => {
const controller = new AbortController();
const pending = highlightCodeWithBudget(
"const value = Math.max(42, 7);\n".repeat(3_000),
"file.ts",
{ yieldToEventLoop: setImmediate, signal: controller.signal },
);
await setImmediate();
controller.abort();
expect(await pending).toBeNull();
});
});
@@ -0,0 +1,49 @@
import { highlightCodeInSteps } from "./highlighter.js";
import type { HighlightToken } from "./types.js";
// Full source, rather than patch size, bounds parser and token allocation work.
// Each token consumes a source code unit or an empty line: at most the byte cap + 1 records.
export const HIGHLIGHT_MAX_SOURCE_BYTES = 768 * 1024;
const SLICE_MS = 4;
/**
* Returns null when source exceeds the budget or the request is cancelled.
* The host supplies a real event-loop yield (setImmediate on the daemon).
* Existing synchronous highlighting remains available for its existing consumers.
*/
export async function highlightCodeWithBudget(
code: string,
filename: string,
options: { yieldToEventLoop: () => Promise<void>; signal?: { readonly aborted: boolean } },
): Promise<HighlightToken[][] | null> {
if (options.signal?.aborted || exceedsSourceBudget(code)) return null;
const steps = highlightCodeInSteps(code, filename);
let sliceStarted = Date.now();
try {
for (;;) {
if (options.signal?.aborted) return null;
const step = steps.next();
if (step.done) return step.value;
if (Date.now() - sliceStarted >= SLICE_MS) {
await options.yieldToEventLoop();
sliceStarted = Date.now();
}
}
} finally {
steps.return([]);
}
}
function exceedsSourceBudget(code: string): boolean {
if (code.length > HIGHLIGHT_MAX_SOURCE_BYTES) return true;
let bytes = 0;
for (const char of code) {
const point = char.codePointAt(0)!;
if (point < 0x80) bytes++;
else if (point < 0x800) bytes += 2;
else if (point < 0x10000) bytes += 3;
else bytes += 4;
if (bytes > HIGHLIGHT_MAX_SOURCE_BYTES) return true;
}
return false;
}
+1
View File
@@ -15,3 +15,4 @@ export {
isSyntaxThemeId,
resolveSyntaxColors,
} from "./themes.js";
export { highlightCodeWithBudget, HIGHLIGHT_MAX_SOURCE_BYTES } from "./budgeted-highlighter.js";
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
describe("InMemoryAgentTimelineStore", () => {
@@ -177,7 +179,10 @@ describe("projected sequence ownership", () => {
store.append("a", { type: "assistant_message", text: "Answer" });
store.append("a", { ...tool, status: "completed" });
store.initialize("b", { rows: store.getRows("a") });
expect(store.getLastItem("b")).toMatchObject({ type: "tool_call", status: "completed" });
expect(store.getLastAssistantMessage("b")).toBe("Answer");
expect(store.append("b", { type: "user_message", text: "next" }).seq).toBe(4);
expect(store.getLastItem("b")).toEqual({ type: "user_message", text: "next" });
expect(store.fetch("b").rows.map((row) => row.seqStart)).toEqual([1, 2, 4]);
});
it("returns the last projected assistant message without joining different messages", () => {
@@ -211,3 +216,151 @@ it("includes transitive tool updates in a contiguous projected tail", () => {
expect(tail.rows.map((row) => row.seqStart)).toEqual([1, 2, 3, 5]);
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
});
it("serves tail 200 from 100,000 retained rows without stalling the loop", async () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("large");
for (let index = 0; index < 100_000; index++) {
store.append("large", { type: "user_message", text: String(index) });
}
// Generation and its GC are outside the measured request window.
await new Promise<void>((resolve) => setImmediate(resolve));
const started = performance.now();
const probe = new Promise<number>((resolve) =>
setImmediate(() => resolve(performance.now() - started)),
);
let page = store.fetch("large", { limit: 200 });
for (let repeat = 1; repeat < 8; repeat++) page = store.fetch("large", { limit: 200 });
const duration = performance.now() - started;
const loopGap = await probe;
console.info({ duration, loopGap });
expect(page.rows).toHaveLength(200);
expect(page.startSeq).toBe(99_801);
expect(page.endSeq).toBe(100_000);
// CI runners can pause for scheduling/GC; the local per-request 2 ms gate
// is measured separately. Eight requests amplify the old full-history work.
expect(duration).toBeLessThan(50);
expect(loopGap).toBeLessThan(50);
});
it("matches the previous reprojecting selector on seeded randomized timelines", () => {
let seed = 0x76aabbcc;
const random = (bound: number) => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return Math.floor((seed / 0x1_0000_0000) * bound);
};
for (let scenario = 0; scenario < 100; scenario++) {
const store = new InMemoryAgentTimelineStore();
store.initialize("a", { epoch: "e" });
for (let seq = 1; seq <= 100; seq++) {
const identity = String(random(8));
const text = String(seq);
const items: AgentTimelineItem[] = [
{ type: "user_message", text },
{ type: "assistant_message", text },
{ type: "assistant_message", text, messageId: identity },
{ type: "reasoning", text },
{
type: "tool_call",
callId: identity,
name: "shell",
status: "running",
error: null,
detail: { type: "plain_text", label: text },
},
{
type: "plugin",
id: identity,
pluginId: "test",
kind: "state",
version: 1,
data: { text },
},
];
store.append("a", items[random(items.length)], { turnId: String(random(3)) });
}
// Include rehydration of already-projected rows, with non-monotonic seqEnd.
store.initialize("b", { rows: store.getRows("a"), epoch: "e" });
for (const agentId of ["a", "b"]) {
assertSelectionMatchesReference(store, agentId);
store.append(agentId, { type: "assistant_message", text: "later" });
assertSelectionMatchesReference(store, agentId);
}
}
});
function assertSelectionMatchesReference(store: InMemoryAgentTimelineStore, agentId: string): void {
for (const direction of ["tail", "before", "after"] as const) {
for (const limit of [0, 1, 7, 200]) {
for (const cursorSeq of [0, 1, 25, 50, 99, 100, 150]) {
const actual = store.fetch(agentId, {
direction,
limit,
cursor: { epoch: "e", seq: cursorSeq },
});
const expected = selectReferenceProjectedTimelinePage({
rows: store.getRows(agentId),
bounds: actual.window,
direction,
limit,
cursorSeq,
});
expect({
...expected,
entries: undefined,
rows: expected.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)),
}).toEqual({
entries: undefined,
rows: actual.rows,
startSeq: actual.startSeq,
endSeq: actual.endSeq,
hasOlder: actual.hasOlder,
hasNewer: actual.hasNewer,
});
}
}
}
}
it("continues a scroll across appends and lifecycle collapse without losing source coverage", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("a", { epoch: "e" });
const tool: AgentTimelineItem = {
type: "tool_call",
callId: "t",
name: "shell",
status: "running",
error: null,
detail: { type: "plain_text", label: "work" },
};
store.append("a", tool);
for (let seq = 2; seq <= 5; seq++) store.append("a", { type: "user_message", text: String(seq) });
const first = store.fetch("a", { direction: "after", cursor: { epoch: "e", seq: 0 }, limit: 2 });
expect(first.endSeq).toBe(2);
store.append("a", { ...tool, status: "completed" });
store.append("a", { type: "assistant_message", text: "Done" });
expect(first.rows[0].item).toMatchObject({ status: "running" });
const second = store.fetch("a", {
direction: "after",
cursor: { epoch: "e", seq: first.endSeq! },
limit: 2,
});
expect(second.rows.map((row) => row.seqStart)).toEqual([3, 4]);
expect(second.endSeq).toBe(4);
const third = store.fetch("a", {
direction: "after",
cursor: { epoch: "e", seq: second.endSeq! },
limit: 2,
});
expect(third.rows.map((row) => row.seqStart)).toEqual([1, 5]);
expect(third.rows[0].item).toMatchObject({ status: "completed" });
expect(third.endSeq).toBe(6);
const last = store.fetch("a", {
direction: "after",
cursor: { epoch: "e", seq: third.endSeq! },
limit: 2,
});
expect(last.rows.map((row) => row.item)).toEqual([{ type: "assistant_message", text: "Done" }]);
expect(last.endSeq).toBe(7);
expect(last.hasNewer).toBe(false);
});
@@ -1,9 +1,5 @@
import { randomUUID } from "node:crypto";
import {
TimelineProjection,
selectProjectedTimelinePage,
type ProjectedTimelineRow,
} from "./timeline-projection.js";
import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type {
AgentTimelineFetchOptions,
@@ -43,8 +39,7 @@ export class InMemoryAgentTimelineStore {
? options.rows.map(cloneRow)
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
const nextSeq = rows.reduce((next, row) => Math.max(next, row.seq + 1), options?.nextSeq ?? 1);
const projection = new TimelineProjection();
for (const row of rows) projection.append(row);
const projection = new TimelineProjection(rows);
this.states.set(agentId, {
epoch: options?.epoch ?? randomUUID(),
projection,
@@ -97,18 +92,17 @@ export class InMemoryAgentTimelineStore {
const state = this.requireState(agentId);
const direction = options?.direction ?? "tail";
const cursor = options?.cursor;
const rows = state.projection.getRows();
const hasRows = state.minSeq !== 0;
const window = { minSeq: state.minSeq, maxSeq: state.nextSeq - 1, nextSeq: state.nextSeq };
const staleCursor = cursor !== undefined && cursor.epoch !== state.epoch;
const gap =
!staleCursor &&
direction === "after" &&
cursor !== undefined &&
rows.length > 0 &&
hasRows &&
cursor.seq < state.minSeq - 1;
const reset = staleCursor || gap;
const page = selectProjectedTimelinePage({
rows,
const page = state.projection.selectPage({
bounds: window,
direction: reset ? "tail" : direction,
cursorSeq: cursor?.seq,
@@ -150,14 +144,11 @@ export class InMemoryAgentTimelineStore {
getLastItem(agentId: string): AgentTimelineItem | null {
const state = this.requireState(agentId);
return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null;
return state.projection.getLastItem(state.nextSeq - 1);
}
getLastAssistantMessage(agentId: string): string | null {
const row = this.requireState(agentId)
.projection.getRows()
.findLast((candidate) => candidate.item.type === "assistant_message");
return row?.item.type === "assistant_message" ? row.item.text : null;
return this.requireState(agentId).projection.getLastAssistantMessage();
}
private requireState(agentId: string): AgentTimelineState {
@@ -99,16 +99,17 @@ afterEach(() => {
});
describe("mutable provider config owner", () => {
test("publishes provider changes after commit and emits nothing on rollback", () => {
test("publishes provider changes after commit and emits nothing on rollback", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, mutableConfig({ version: 1 }));
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: { codex: { enabled: true } },
providerOverrides: CONTROLLED_PROVIDERS,
extraClients: { codex: controlledCodexClient(async () => catalog("initial")) },
});
const cwd = path.resolve("/tmp/provider-config-owner");
manager.getSnapshot(cwd);
await manager.refreshSnapshotForCwd({ cwd });
const events: string[] = [];
manager.on("change", ({ current }) => events.push(current.cwd));
let agentManagerState = manager.getAgentManagerProviderState();
@@ -125,7 +126,7 @@ describe("mutable provider config owner", () => {
});
try {
store.patch({ providers: { codex: { enabled: false } } });
await store.patch({ providers: { codex: { enabled: false } } });
expect(events).toEqual([cwd]);
expect(agentManagerState.providerDefinitions.codex).toMatchObject({ enabled: false });
@@ -133,7 +134,7 @@ describe("mutable provider config owner", () => {
const unsubscribeFailure = store.onApply(() => {
throw new Error("later owner failed");
});
expect(() => store.patch({ providers: { codex: { enabled: true } } })).toThrow(
await expect(store.patch({ providers: { codex: { enabled: true } } })).rejects.toThrow(
"later owner failed",
);
unsubscribeFailure();
@@ -150,7 +151,7 @@ describe("mutable provider config owner", () => {
}
});
test("does no provider work for unrelated CORS, Git, and app URL reloads", () => {
test("does no provider work for unrelated CORS, Git, and app URL reloads", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-"));
tempDirs.push(paseoHome);
const initial: PersistedConfig = {
@@ -174,9 +175,10 @@ describe("mutable provider config owner", () => {
});
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: { codex: { enabled: true } },
providerOverrides: CONTROLLED_PROVIDERS,
extraClients: { codex: controlledCodexClient(async () => catalog("initial")) },
});
manager.getSnapshot("/tmp/provider-config-owner");
await manager.refreshSnapshotForCwd({ cwd: "/tmp/provider-config-owner" });
const events: string[] = [];
manager.on("change", ({ current }) => events.push(current.cwd));
let registryUpdates = 0;
@@ -200,7 +202,7 @@ describe("mutable provider config owner", () => {
};
writeFileSync(configPath, `${JSON.stringify(reloaded, null, 2)}\n`, "utf-8");
expect(store.reload().appliedPaths).toEqual([
expect((await store.reload()).appliedPaths).toEqual([
"app.baseUrl",
"daemon.cors.allowedOrigins",
"daemon.git.maxProcessConcurrency",
@@ -245,7 +247,7 @@ describe("mutable provider config owner", () => {
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1));
expect(events).toEqual([]);
store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } });
await store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } });
expect(events).toEqual(["loading:none"]);
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(2));
@@ -311,9 +313,9 @@ describe("mutable provider config owner", () => {
expect(events).toEqual([]);
const before = manager.getSnapshot(cwd);
expect(() =>
await expect(
store.patch({ providers: { codex: { enabled: true, label: "Rejected Codex" } } }),
).toThrow("later owner failed");
).rejects.toThrow("later owner failed");
expect(events).toEqual([]);
expect(observedDuringApply).toEqual([{ label: "Codex", snapshot: before }]);
expect(observedDuringApply[0]!.snapshot).toBe(before);
@@ -310,7 +310,7 @@ describe("PluginAgentClientRegistry", () => {
});
const own = { nested: { replace: "agent" } };
const agent = await manager.createAgent(
{ provider: "plugin-direct", cwd: "/tmp", providerOptions: own },
{ provider: "plugin-direct", cwd: tmpdir(), providerOptions: own },
undefined,
{ workspaceId: undefined },
);
@@ -1,10 +1,11 @@
import { once } from "node:events";
import type { Query } from "@anthropic-ai/claude-agent-sdk";
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentLaunchContext } from "../../agent-sdk-types.js";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryInput } from "./query.js";
import { claudeQuery, type ClaudeQueryInput } from "./query.js";
function createQueryMock(events: unknown[]): Query {
let index = 0;
@@ -29,6 +30,48 @@ function createQueryMock(events: unknown[]): Query {
}
describe("Claude SDK env", () => {
test.each(["node", "bun", process.execPath])(
"identifies the spawned %s process as Paseo after clearing parent session env",
async (command) => {
let output = "";
let exited = Promise.resolve<unknown[]>([]);
const query = claudeQuery(
{ prompt: "", options: {} },
{
runtimeSettings: { env: { CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } },
launchEnv: { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" },
queryFactory: ({ options }) => {
if (!options.spawnClaudeCodeProcess) {
throw new Error("Missing Claude process launcher");
}
const child = options.spawnClaudeCodeProcess({
command,
args: [
"-e",
"process.stdout.write(JSON.stringify({ entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT, parentSession: process.env.CLAUDECODE ?? null }))",
],
cwd: process.cwd(),
env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: "sdk-ts", CLAUDECODE: "1" },
signal: new AbortController().signal,
});
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
exited = once(child, "close");
return createQueryMock([]);
},
},
);
try {
expect(await exited).toEqual([0, null]);
expect(JSON.parse(output)).toEqual({ entrypoint: "Paseo", parentSession: null });
} finally {
query.close();
}
},
);
test("forwards launch-context env through Claude process env", async () => {
let capturedEnv: Record<string, string | undefined> | undefined;
let capturedPerTaskStopAffordance: boolean | undefined;
@@ -1,9 +1,13 @@
import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { open } from "node:fs/promises";
import { performance } from "node:perf_hooks";
import * as os from "node:os";
import * as path from "node:path";
import pino from "pino";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestPaseoDaemon } from "../../../test-utils/paseo-daemon.js";
import type { ClaudeRewindSdk } from "./rewind.js";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { withClaudeReplayHistory } from "./transcript-history.js";
@@ -32,7 +36,10 @@ describe("ClaudeAgentSession persisted history load", () => {
let configDir: string;
let records: LogRecord[];
async function resume(queryFactory?: ClaudeQueryFactory): Promise<AgentSession> {
async function resume(
queryFactory?: ClaudeQueryFactory,
rewindSdk?: ClaudeRewindSdk,
): Promise<AgentSession> {
const client = new ClaudeAgentClient({
logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }),
queryFactory:
@@ -42,10 +49,36 @@ describe("ClaudeAgentSession persisted history load", () => {
}),
resolveBinary: async () => process.execPath,
resolveVersion: async () => "2.1.220",
rewindSdk,
});
return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd });
}
function replyFactory(text: string, messageId = "live-reply"): ClaudeQueryFactory {
return (() => {
const messages = [
{ type: "assistant", uuid: messageId, message: { content: text } },
{ type: "result", subtype: "success", total_cost_usd: 0 },
];
return {
async next() {
const value = messages.shift();
return value ? { done: false, value } : { done: true, value: undefined };
},
close() {},
async return() {
return { done: true, value: undefined };
},
async supportedCommands() {
return [];
},
[Symbol.asyncIterator]() {
return this;
},
};
}) as ClaudeQueryFactory;
}
function transcriptPath(): string {
return path.join(claudeProjectDirSync(cwd, { configDir }), `${SESSION_ID}.jsonl`);
}
@@ -65,6 +98,288 @@ describe("ClaudeAgentSession persisted history load", () => {
rmSync(tempRoot, { recursive: true, force: true });
});
test("serves unrelated health requests while resuming 32 MiB of ordered history", async () => {
const startedAt = performance.now();
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
const file = await open(transcriptPath(), "w");
const text = "x".repeat(16 * 1024);
let bytes = 0;
let count = 0;
try {
while (bytes < 32 * 1024 * 1024) {
const line =
JSON.stringify({ type: "assistant", message: { content: `${count}:${text}` } }) + "\n";
bytes += Buffer.byteLength(line);
await file.write(line);
count++;
}
} finally {
await file.close();
}
const daemon = await createTestPaseoDaemon();
let lastTick = performance.now();
let longestGap = 0;
const probe = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - lastTick);
lastTick = now;
}, 2);
let loading = true;
let answeredDuringLoad = false;
const health = fetch(`http://127.0.0.1:${daemon.port}/api/health`).then(async (response) => {
expect(response.status).toBe(200);
await response.text();
answeredDuringLoad = loading;
return undefined;
});
let session: AgentSession | undefined;
try {
session = await resume(replyFactory("live after large resume"));
let index = 0;
for await (const event of session.streamHistory()) {
expect(event).toEqual({
type: "timeline",
provider: "claude",
timestamp: undefined,
item: { type: "assistant_message", text: `${index}:${text}` },
});
index++;
}
loading = false;
await health;
await new Promise((resolve) => setTimeout(resolve, 5));
console.info(
JSON.stringify({
bytes,
count,
longestGap,
answeredDuringLoad,
elapsedMs: performance.now() - startedAt,
}),
);
expect(index).toBe(count);
expect(longestGap).toBeLessThan(50);
expect(answeredDuringLoad).toBe(true);
const live = await collectSessionTurnEvents(session, "follow up");
expect(
live
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
.map((event) => (event.type === "timeline" ? event.item : null)),
).toEqual([
{ type: "assistant_message", messageId: "live-reply", text: "live after large resume" },
]);
} finally {
clearInterval(probe);
await session?.close();
await daemon.close();
}
}, 60_000);
test("rebinds ordered history before returning from rewind and accepts a live turn", async () => {
const entries = [
{ type: "user", uuid: "u1", message: { content: "first" } },
{ type: "assistant", uuid: "a1", message: { content: "first reply" } },
{ type: "user", uuid: "u2", message: { content: "second" } },
{ type: "assistant", uuid: "a2", message: { content: "second reply" } },
];
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(transcriptPath(), entries.map((entry) => JSON.stringify(entry)).join("\n"));
const forkId = "rebound-history";
const forkFile = path.join(path.dirname(transcriptPath()), `${forkId}.jsonl`);
writeFileSync(
forkFile,
entries
.slice(0, 2)
.map((entry) => JSON.stringify(entry))
.join("\n"),
);
const forks: unknown[] = [];
const late = {
type: "assistant",
uuid: "retired-reply",
message: { content: "retired query" },
};
let pendingRead: ((value: unknown) => void) | undefined;
let releaseRetirement!: () => void;
const retirementGate = new Promise<void>((resolve) => {
releaseRetirement = resolve;
});
let retirementStarted!: () => void;
const retiring = new Promise<void>((resolve) => {
retirementStarted = resolve;
});
let firstQuery = true;
let retired = false;
const queryFactory = (() => {
if (!firstQuery) return replyFactory("live after rebind")({} as never);
firstQuery = false;
const messages = [
{ type: "assistant", uuid: "before-rebind", message: { content: "before rebind" } },
{ type: "result", subtype: "success", total_cost_usd: 0 },
];
return {
async next() {
const value = messages.shift();
if (value) return { done: false, value };
if (retired) return { done: true, value: undefined };
return new Promise((resolve) => {
pendingRead = resolve;
});
},
close() {
retired = true;
pendingRead?.({ done: false, value: late });
},
async return() {
retirementStarted();
await retirementGate;
return { done: true, value: undefined };
},
async supportedCommands() {
return [];
},
[Symbol.asyncIterator]() {
return this;
},
};
}) as ClaudeQueryFactory;
const session = await resume(queryFactory, {
async forkSession(sessionId, options) {
forks.push({ sessionId, ...options });
return { sessionId: forkId };
},
});
try {
const original = await Array.fromAsync(session.streamHistory());
expect(original).toHaveLength(4);
await collectSessionTurnEvents(session, "before rewind");
const emitted: AgentStreamEvent[] = [];
const subscription = session.subscribe((event) => emitted.push(event));
let rewindDone = false;
const rewind = (async () => {
await session.revertConversation!({ messageId: "u2" });
rewindDone = true;
})();
await retiring;
let historyDone = false;
const reading = (async () => {
const history = await Array.fromAsync(session.streamHistory());
historyDone = true;
return history;
})();
const readingAgain = Array.fromAsync(session.streamHistory());
await Promise.resolve();
expect(rewindDone).toBe(false);
expect(historyDone).toBe(false);
releaseRetirement();
await rewind;
subscription();
expect(retired).toBe(true);
expect(
emitted.filter(
(event) => event.type === "timeline" && event.item.type === "assistant_message",
),
).toEqual([
{
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: `Claude switched to a new session: ${SESSION_ID} -> ${forkId}`,
},
},
]);
expect(forks).toEqual([
{ sessionId: SESSION_ID, upToMessageId: "a1", transcriptPath: transcriptPath() },
]);
expect(session.id).toBe(forkId);
const [history, secondRead] = await Promise.all([reading, readingAgain]);
expect(history).toEqual(original.slice(0, 2));
expect(secondRead).toEqual([]);
const live = await collectSessionTurnEvents(session, "continue the fork");
expect(
live
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
.map((event) => (event.type === "timeline" ? event.item : null)),
).toEqual([
{ type: "assistant_message", messageId: "live-reply", text: "live after rebind" },
]);
} finally {
releaseRetirement();
await session.close();
}
});
test("reports a failed rewind without poisoning readiness when query retirement throws", async () => {
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(
transcriptPath(),
[
{ type: "user", uuid: "u1", message: { content: "first" } },
{ type: "assistant", uuid: "a1", message: { content: "reply" } },
{ type: "user", uuid: "u2", message: { content: "second" } },
]
.map((entry) => JSON.stringify(entry))
.join("\n"),
);
let firstQuery = true;
let finishRead: ((value: unknown) => void) | undefined;
const queryFactory = ((input) => {
const query = replyFactory(
firstQuery ? "before retirement" : "still available",
firstQuery ? "before-retirement" : "live-reply",
)(input);
if (!firstQuery) return query;
firstQuery = false;
let reads = 0;
return {
...query,
async next() {
if (++reads <= 2) return query.next();
return new Promise((resolve) => {
finishRead = resolve;
});
},
close() {
finishRead?.({ done: true, value: undefined });
throw new Error("retirement failed");
},
[Symbol.asyncIterator]() {
return this;
},
};
}) as ClaudeQueryFactory;
const session = await resume(queryFactory, {
async forkSession() {
return { sessionId: "failed-rebind" };
},
});
try {
await Array.fromAsync(session.streamHistory());
await collectSessionTurnEvents(session, "before rewind");
await expect(session.revertConversation!({ messageId: "u2" })).rejects.toThrow(
"retirement failed",
);
expect(records).toContainEqual(
expect.objectContaining({
level: pino.levels.values.warn,
msg: "Failed to rebind Claude session",
sessionId: "failed-rebind",
err: expect.objectContaining({ message: "retirement failed" }),
}),
);
expect(session.id).toBe(SESSION_ID);
const live = await collectSessionTurnEvents(session, "after failed rebind");
expect(
live
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
.map((event) => (event.type === "timeline" ? event.item : null)),
).toEqual([{ type: "assistant_message", messageId: "live-reply", text: "still available" }]);
} finally {
await session.close();
}
});
test.each(["file_path", "files"])(
"replays %s without probing targets and preserves live existence checks",
async (field) => {
@@ -300,21 +615,21 @@ describe("ClaudeAgentSession persisted history load", () => {
);
});
test("loads readable history when the temporary directory is unavailable", () => {
test("loads readable history when the temporary directory is unavailable", async () => {
const file = transcriptPath();
const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } };
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify(entry));
const unavailable = path.join(tempRoot, "unavailable-tmp");
for (const variable of ["TMPDIR", "TEMP", "TMP"]) vi.stubEnv(variable, unavailable);
withClaudeReplayHistory(file, (history) => {
expect([...history.parentEntries]).toEqual([entry]);
await withClaudeReplayHistory(file, async (history) => {
expect(await Array.fromAsync(history.parentEntries)).toEqual([entry]);
});
});
test.each(["parent", "child", "workflow"])(
"preserves indexed %s history when its transcript disappears between passes",
(target) => {
async (target) => {
const parent = transcriptPath();
const sessionDirectory = parent.slice(0, -".jsonl".length);
const child = path.join(sessionDirectory, "subagents", "agent-child.jsonl");
@@ -334,14 +649,16 @@ describe("ClaudeAgentSession persisted history load", () => {
writeFileSync(parent, JSON.stringify(parentEntry));
writeFileSync(child, JSON.stringify(childEntry));
writeFileSync(workflow, JSON.stringify(childEntry));
withClaudeReplayHistory(parent, (history) => {
await withClaudeReplayHistory(parent, async (history) => {
rmSync({ parent, child, workflow }[target]);
// Ownership facts and visible payloads are separate passes over the same source.
for (let pass = 0; pass < 2; pass++) {
expect([...history.parentEntries]).toEqual([parentEntry]);
expect(await Array.fromAsync(history.parentEntries)).toEqual([parentEntry]);
expect(history.subagents).toHaveLength(1);
expect([...history.subagents[0]!.entries]).toEqual([childEntry]);
expect([...history.workflowEntriesByRunId.get("wf_test")!]).toEqual([childEntry]);
expect(await Array.fromAsync(history.subagents[0]!.entries)).toEqual([childEntry]);
expect(await Array.fromAsync(history.workflowEntriesByRunId.get("wf_test")!)).toEqual([
childEntry,
]);
}
});
},
@@ -1,3 +1,4 @@
import { setImmediate } from "node:timers/promises";
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
import { validateProviderOptions } from "../../provider-options.js";
import type { ChildProcess } from "node:child_process";
@@ -82,7 +83,7 @@ import {
type ClaudeRewindSdk,
} from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js";
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPath } from "./project-dir.js";
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
import {
isProviderImageMarkdown,
@@ -1634,7 +1635,7 @@ export class ClaudeAgentClient implements AgentClient {
cwd: merged.cwd,
};
const claudeConfig = this.assertConfig(mergedConfig);
return new ClaudeAgentSession(claudeConfig, {
const session = new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
handle,
@@ -1645,6 +1646,8 @@ export class ClaudeAgentClient implements AgentClient {
resolveBinary: this.resolveBinary,
rewindSdk: this.rewindSdk,
});
await session.ensureHistoryReady();
return session;
}
async getCatalogCacheKey(_options: FetchCatalogOptions): Promise<string> {
@@ -2329,6 +2332,9 @@ class ClaudeAgentSession implements AgentSession {
{ type: "provider_subagent" }
>[] = [];
private historyPending = false;
// Readiness never rejects; history/rebind failures are logged at their owning boundary.
private historyReady: Promise<void> | null = null;
private historySessionId: string | null = null;
private replayingHistory = false;
private turnState: TurnState = "idle";
private nextTurnOrdinal = 1;
@@ -2382,7 +2388,7 @@ class ClaudeAgentSession implements AgentSession {
}
this.claudeSessionId = handle.sessionId;
this.persistence = handle;
this.loadPersistedHistory(handle.sessionId);
this.historySessionId = handle.sessionId;
} else {
this.claudeSessionId = null;
this.persistence = null;
@@ -2462,6 +2468,7 @@ class ClaudeAgentSession implements AgentSession {
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<{ turnId: string }> {
await this.ensureHistoryReady();
if (this.closed) {
throw new Error("Claude session is closed");
}
@@ -2632,6 +2639,7 @@ class ClaudeAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
await this.ensureHistoryReady();
if (
!this.historyPending ||
(this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0)
@@ -2643,7 +2651,9 @@ class ClaudeAgentSession implements AgentSession {
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
let replayed = 0;
for (const entry of history) {
if (++replayed % 32 === 0) await setImmediate();
yield {
type: "timeline",
item: entry.item,
@@ -2961,6 +2971,7 @@ class ClaudeAgentSession implements AgentSession {
"provider.claude.session_close.start",
);
this.closed = true;
await this.ensureHistoryReady();
this.rejectAllPendingPermissions(new Error("Claude session closed"));
this.cancelCurrentTurn?.();
this.subscribers.clear();
@@ -3001,7 +3012,7 @@ class ClaudeAgentSession implements AgentSession {
// (see `claude --help`), so the SDK's persistSession=false is silently dropped
// in stream-json mode. Sweep the transcript ourselves so ephemeral runs
// (metadata generator, branch-name generator) don't show up as resumable.
const historyPath = this.resolveHistoryPath(this.claudeSessionId);
const historyPath = await this.resolveHistoryPath(this.claudeSessionId);
if (historyPath) {
try {
await promises.rm(historyPath, { force: true });
@@ -3045,6 +3056,7 @@ class ClaudeAgentSession implements AgentSession {
}
async revertConversation(input: { messageId: string }): Promise<void> {
await this.ensureHistoryReady();
const target = this.resolveConversationRewindTarget(input.messageId);
if (target.kind === "fresh-session") {
this.startFreshConversationSession();
@@ -3054,16 +3066,15 @@ class ClaudeAgentSession implements AgentSession {
await revertClaudeConversation({
sdk: this.rewindSdk,
sessionId: currentSessionId,
transcriptPath: currentSessionId ? this.resolveHistoryPath(currentSessionId) : null,
transcriptPath: currentSessionId ? await this.resolveHistoryPath(currentSessionId) : null,
messageId: target.messageId,
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
setSessionId: (sessionId) => {
this.rebindConversationSession(sessionId);
},
setSessionId: (sessionId) => this.rebindConversationSession(sessionId),
});
}
async revertFiles(input: { messageId: string }): Promise<void> {
await this.ensureHistoryReady();
const messageId = await this.resolveClaudeMessageId(input.messageId);
await revertClaudeFiles({
query: await this.ensureQuery(),
@@ -3238,38 +3249,52 @@ class ClaudeAgentSession implements AgentSession {
return candidates;
}
private rebindConversationSession(sessionId: string): void {
const oldSessionId = this.claudeSessionId;
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = null;
this.persistence = null;
this.cachedRuntimeInfo = null;
this.queryRestartNeeded = true;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
this.userMessageIds = [];
this.emittedUserMessageIds.clear();
this.rewindTurnAnchors.length = 0;
this.taskState.reset();
this.loadPersistedHistory(sessionId);
if (oldSessionId && oldSessionId !== sessionId) {
this.dispatchEvents([
{
type: "timeline",
provider: "claude",
item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId),
},
{
type: "thread_started",
provider: "claude",
sessionId,
},
]);
}
private rebindConversationSession(sessionId: string): Promise<void> {
const previousReady = this.ensureHistoryReady();
const rebind = (async () => {
await previousReady;
await this.retireQuery();
if (this.closed) return;
const oldSessionId = this.claudeSessionId;
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = null;
this.persistence = null;
this.cachedRuntimeInfo = null;
this.queryRestartNeeded = true;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
this.userMessageIds = [];
this.emittedUserMessageIds.clear();
this.rewindTurnAnchors.length = 0;
this.taskState.reset();
this.historySessionId = sessionId;
await this.loadPersistedHistory(sessionId);
if (this.closed) return;
if (oldSessionId && oldSessionId !== sessionId) {
this.dispatchEvents([
{
type: "timeline",
provider: "claude",
item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId),
},
{
type: "thread_started",
provider: "claude",
sessionId,
},
]);
}
})();
this.historyReady = rebind.catch((error) => {
this.logger.warn({ err: error, sessionId }, "Failed to rebind Claude session");
});
return rebind;
}
private startFreshConversationSession(): void {
this.historySessionId = null;
this.historyReady = Promise.resolve();
const sessionId = randomUUID();
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = sessionId;
@@ -3390,7 +3415,43 @@ class ClaudeAgentSession implements AgentSession {
return { kind: "fresh-session" };
}
private async retireQuery(): Promise<void> {
const oldQuery = this.query;
if (!oldQuery) return;
const oldInput = this.input;
// Null out query/input BEFORE awaiting the old iterator's return so the
// old pump sees this.query !== activeQuery and skips failActiveTurns.
this.query = null;
this.input = null;
this.queryPumpPromise = null;
this.queryRestartNeeded = false;
// Ending the input retires the process on purpose. Detach first so its
// exit is not reported as a crash.
const retiredChild = this.childProcess;
this.childProcess = null;
if (retiredChild) this.failRunningRuntimeTasks();
oldInput?.end();
oldQuery.close?.();
try {
await oldQuery.return?.();
} catch {
/* ignore */
}
// Tree-kill the old process tree now that the SDK has cleaned up.
// If we skip this, MCP children of the previous claude process can
// survive as orphans when the session spawns a replacement query.
if (retiredChild) {
await terminateWithTreeKill(retiredChild, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
}).catch(() => {
/* process may already be dead */
});
}
}
private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<Query> {
await this.ensureHistoryReady();
// Controls use the running turn's query. Pending launch settings take effect
// after that turn finishes, when its active query handle has been cleared.
if (this.query && (!this.queryRestartNeeded || this.activeForegroundQuery === this.query)) {
@@ -3398,37 +3459,7 @@ class ClaudeAgentSession implements AgentSession {
}
if (this.queryRestartNeeded && this.query) {
const oldQuery = this.query;
const oldInput = this.input;
// Null out query/input BEFORE awaiting the old iterator's return so the
// old pump sees this.query !== activeQuery and skips failActiveTurns.
this.query = null;
this.input = null;
this.queryPumpPromise = null;
this.queryRestartNeeded = false;
// Ending the input retires the process on purpose. Detach first so its
// exit is not reported as a crash.
const retiredChild = this.childProcess;
this.childProcess = null;
if (retiredChild) this.failRunningRuntimeTasks();
oldInput?.end();
oldQuery.close?.();
try {
await oldQuery.return?.();
} catch {
/* ignore */
}
// Tree-kill the old process tree now that the SDK has cleaned up.
// If we skip this, MCP children of the previous claude process can
// survive as orphans when the session spawns a replacement query.
if (retiredChild) {
await terminateWithTreeKill(retiredChild, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
}).catch(() => {
/* process may already be dead */
});
}
await this.retireQuery();
}
// Preserve claudeSessionId across query recreation so buildOptions() passes
@@ -4095,13 +4126,15 @@ class ClaudeAgentSession implements AgentSession {
);
};
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
if (!this.ownsQuery(activeQuery)) return true;
this.processingSdkMessages++;
try {
this.runtimeSafety.observe(message);
logRawMessage(message);
consecutiveInterruptAbortRecoveries = 0;
if (await this.handleMissingResumedConversation(message, activeQuery)) return true;
await this.routeSdkMessageFromPump(message);
if (!this.ownsQuery(activeQuery)) return true;
this.routeSdkMessageFromPump(message);
return false;
} finally {
this.processingSdkMessages--;
@@ -4221,7 +4254,11 @@ class ClaudeAgentSession implements AgentSession {
return isClaudeTurnInit(message) && !this.activeForegroundTurnId;
}
private async routeSdkMessageFromPump(message: SDKMessage): Promise<void> {
private ownsQuery(query: Query): boolean {
return !this.closed && this.query === query;
}
private routeSdkMessageFromPump(message: SDKMessage): void {
if (this.consumeSessionStateChange(message)) {
return;
}
@@ -4255,7 +4292,7 @@ class ClaudeAgentSession implements AgentSession {
"provider.claude.parsed_event",
);
const events = await this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
const events = this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
if (events.length === 0) {
return;
@@ -4290,11 +4327,11 @@ class ClaudeAgentSession implements AgentSession {
this.dispatchEvents(events);
}
private async buildPumpedMessageEvents(
private buildPumpedMessageEvents(
message: SDKMessage,
messageIdHint: string | null,
turnId: string | null,
): Promise<AgentStreamEvent[]> {
): AgentStreamEvent[] {
const messageEvents = this.translateMessageToEvents(message, {
suppressAssistantText: true,
suppressReasoning: true,
@@ -5272,22 +5309,31 @@ class ClaudeAgentSession implements AgentSession {
}
}
private loadPersistedHistory(sessionId: string): void {
ensureHistoryReady(): Promise<void> {
if (!this.historyReady) {
this.historyReady = this.historySessionId
? this.loadPersistedHistory(this.historySessionId)
: Promise.resolve();
}
return this.historyReady;
}
private async loadPersistedHistory(sessionId: string): Promise<void> {
let historyPath: string | null = null;
this.replayingHistory = true;
try {
this.taskState.reset();
historyPath = this.resolveHistoryPath(sessionId);
if (!historyPath || !fs.existsSync(historyPath)) {
historyPath = await this.resolveHistoryPath(sessionId);
if (!historyPath || !(await transcriptExists(historyPath))) {
this.logger.info(
{ sessionId, cwd: this.config.cwd, historyPath },
"No Claude transcript to load history from",
);
return;
}
withClaudeReplayHistory(historyPath, (history) => {
const replay = this.ingestPersistedSidechains(history);
this.ingestPersistedHistory(history.parentEntries, replay);
await withClaudeReplayHistory(historyPath, async (history) => {
const replay = await this.ingestPersistedSidechains(history);
await this.ingestPersistedHistory(history.parentEntries, replay);
});
} catch (error) {
this.logger.warn(
@@ -5299,12 +5345,12 @@ class ClaudeAgentSession implements AgentSession {
}
}
private ingestPersistedHistory(
entries: Iterable<ClaudeHistoryEntry>,
private async ingestPersistedHistory(
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
replay: ClaudeReplayOwnership,
): void {
): Promise<void> {
const timeline: PersistedTimelineEntry[] = [];
for (const entry of entries) {
for await (const entry of entries) {
this.ingestPersistedHistoryEntry(entry, timeline, replay);
}
@@ -5314,27 +5360,33 @@ class ClaudeAgentSession implements AgentSession {
}
}
private ingestPersistedSidechains(history: ClaudeReplayHistory): ClaudeReplayOwnership {
const parentFacts = readClaudeReplayParentFacts(history.parentEntries);
private async ingestPersistedSidechains(
history: ClaudeReplayHistory,
): Promise<ClaudeReplayOwnership> {
const parentFacts = await readClaudeReplayParentFacts(history.parentEntries);
// Replay produces the same observations the live task protocol produces, then folds them
// with the same function, so identity and status are derived once for both paths.
const subagentReplay = observeReplaySubagents({
subagents: history.subagents.map((subagent) => ({
const subagents = [];
for (const subagent of history.subagents) {
subagents.push({
...subagent,
parentFacts: readClaudeReplayParentFacts(subagent.entries),
})),
parentFacts: await readClaudeReplayParentFacts(subagent.entries),
});
}
const subagentReplay = await observeReplaySubagents({
subagents,
parent: parentFacts,
convertEntry: (entry) => this.convertHistoryEntry(entry),
});
const observations = [
...subagentReplay.observations,
...observeReplayWorkflows({
...(await observeReplayWorkflows({
workflows: history.workflows,
parentEntries: history.parentEntries,
entriesByRunId: history.workflowEntriesByRunId,
convertEntry: (entry) => this.convertHistoryEntry(entry),
}),
})),
];
const restoredProviderSubagentIds = new Set(
observations
@@ -5421,10 +5473,10 @@ class ClaudeAgentSession implements AgentSession {
}
}
private resolveHistoryPath(sessionId: string): string | null {
private async resolveHistoryPath(sessionId: string): Promise<string | null> {
const cwd = this.config.cwd;
if (!cwd) return null;
return claudeTranscriptPathSync({
return claudeTranscriptPath({
cwd,
sessionId,
configDir: claudeConfigDir(this.harnessEnvironment),
@@ -6191,13 +6243,13 @@ function labelReplayedSubagentCard(
* source consumes. The agentId scrape is kept only as a fallback for sessions recorded before
* Claude Code wrote the meta sidecar.
*/
function readClaudeReplayParentFacts(
entries: Iterable<ClaudeHistoryEntry>,
): ClaudeReplayParentFacts {
async function readClaudeReplayParentFacts(
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
): Promise<ClaudeReplayParentFacts> {
const toolCalls = new Map<string, { title?: string; description?: string }>();
const linksByAgentId = new Map<string, { toolCallId: string; failed: boolean }>();
const outcomes = new Map<string, { failed: boolean }>();
for (const entry of entries) {
for await (const entry of entries) {
const content = toObjectRecord(entry.message)?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -6729,7 +6781,7 @@ async function readClaudeRenamedSessionTitle(input: {
}): Promise<string | null> {
let content: string;
try {
content = await fsPromises.readFile(claudeTranscriptPathSync(input), "utf8");
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
} catch (error) {
// A session with no transcript on disk has no rename to carry over. Every
// other read failure is a real fault and stays explicit.
@@ -6829,3 +6881,13 @@ function readClaudeCommandLifecycle(message: unknown): ClaudeCommandLifecycle |
state: record.state as ClaudeCommandLifecycle["state"],
};
}
async function transcriptExists(file: string): Promise<boolean> {
try {
await fs.promises.access(file);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
@@ -1,5 +1,5 @@
import { existsSync, readdirSync, realpathSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { realpathSync } from "node:fs";
import { realpath, access, readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
@@ -35,21 +35,21 @@ export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOpti
// one `cwd` encodes to when the project moved, the daemon migrated, or Claude Code sees the
// cwd under another name (a Windows binary under WSL). Returns the expected path when no
// transcript exists anywhere.
export function claudeTranscriptPathSync(input: {
export async function claudeTranscriptPath(input: {
cwd: string;
sessionId: string;
configDir?: string;
}): string {
}): Promise<string> {
const options = { configDir: input.configDir };
const fileName = `${input.sessionId}.jsonl`;
const expected = join(claudeProjectDirSync(input.cwd, options), fileName);
if (existsSync(expected)) {
const expected = join(await claudeProjectDir(input.cwd, options), fileName);
if (await exists(expected)) {
return expected;
}
const projectsRoot = join(resolveConfigDir(options), "projects");
let projectDirs: string[];
try {
projectDirs = readdirSync(projectsRoot);
projectDirs = await readdir(projectsRoot);
} catch (error) {
if (isMissingDirectoryError(error)) {
return expected;
@@ -58,13 +58,23 @@ export function claudeTranscriptPathSync(input: {
}
for (const projectDir of projectDirs) {
const candidate = join(projectsRoot, projectDir, fileName);
if (existsSync(candidate)) {
if (await exists(candidate)) {
return candidate;
}
}
return expected;
}
async function exists(file: string): Promise<boolean> {
try {
await access(file);
return true;
} catch (error) {
if (isMissingDirectoryError(error)) return false;
throw error;
}
}
function isMissingDirectoryError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | null)?.code;
return code === "ENOENT" || code === "ENOTDIR";
@@ -2,11 +2,10 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:chi
import { query, type Options, type Query, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk";
import {
createProviderEnv,
createProviderEnvSpec,
type ProviderRuntimeSettings,
} from "../../provider-launch-config.js";
import { buildSelfNodeCommand } from "../../../paseo-env.js";
import { buildSelfNodeCommand, createExternalProcessEnv } from "../../../paseo-env.js";
import { spawnProcess } from "../../../../utils/spawn.js";
// Keep the raw SDK query import in this module only. Claude process launch behavior
@@ -74,11 +73,9 @@ function applyRuntimeSettingsToClaudeOptions(
runtimeSettings,
overlays: [launchEnv],
});
const providerEnv = createProviderEnv({
baseEnv: spawnOptions.env,
runtimeSettings,
overlays: [launchEnv],
});
// Set our identity after sanitization clears the parent session's entrypoint.
providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = "Paseo";
const providerEnv = createExternalProcessEnv(spawnOptions.env, providerEnvSpec.envOverlay);
const selfNodeCommand = isDefaultRuntime
? buildSelfNodeCommand(resolved.args, providerEnv)
: null;
@@ -76,7 +76,7 @@ export async function revertClaudeConversation(input: {
transcriptPath: string | null;
messageId: string;
resolveMessageId?: (messageId: string) => string | Promise<string>;
setSessionId: (sessionId: string) => void;
setSessionId: (sessionId: string) => void | Promise<void>;
}): Promise<void> {
if (!input.sessionId || !input.transcriptPath) {
throw new Error("Claude session is not ready for rewind");
@@ -86,7 +86,7 @@ export async function revertClaudeConversation(input: {
upToMessageId: messageId,
transcriptPath: input.transcriptPath,
});
input.setSessionId(fork.sessionId);
await input.setSessionId(fork.sessionId);
}
export async function revertClaudeFiles(input: {
@@ -108,7 +108,7 @@ export async function revertClaudeConversationAndFiles(input: {
transcriptPath: string | null;
messageId: string;
resolveMessageId?: (messageId: string) => string | Promise<string>;
setSessionId: (sessionId: string) => void;
setSessionId: (sessionId: string) => void | Promise<void>;
}): Promise<void> {
await revertClaudeFiles({
query: input.query,
@@ -45,7 +45,7 @@ function applyToStore(observations: SubagentObservation[]): ProviderSubagentDesc
}
describe("parseClaudeSubagentMeta", () => {
it("reads the real sidecar shape", () => {
it("reads the real sidecar shape", async () => {
expect(
parseClaudeSubagentMeta(
'{"agentType":"general-purpose","description":"Reply banana","toolUseId":"toolu_013i","spawnDepth":1}',
@@ -68,7 +68,7 @@ describe("parseClaudeSubagentMeta", () => {
expect(parseClaudeSubagentMeta(contents)).toBeNull();
});
it("keeps the good fields when one field has the wrong type", () => {
it("keeps the good fields when one field has the wrong type", async () => {
expect(parseClaudeSubagentMeta('{"agentType":"Explore","toolUseId":42}')).toEqual({
agentType: "Explore",
description: undefined,
@@ -79,12 +79,14 @@ describe("parseClaudeSubagentMeta", () => {
});
describe("observeReplaySubagents", () => {
it("links a subagent to its Task call through meta.toolUseId", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
it("links a subagent to its Task call through meta.toolUseId", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations[0]).toMatchObject({
kind: "declared",
@@ -95,31 +97,33 @@ describe("observeReplaySubagents", () => {
});
});
it("replays descendants beneath the sidechain that declared their Task", () => {
it("replays descendants beneath the sidechain that declared their Task", async () => {
const nestedToolUseId = "toolu_012rzYnFZA";
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 },
entries: [],
parentFacts: {
toolCalls: new Map([
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
]),
linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]),
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 },
entries: [],
parentFacts: {
toolCalls: new Map([
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
]),
linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]),
},
},
},
{
agentId: "a6acb4b898",
meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 },
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
{
agentId: "a6acb4b898",
meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 },
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations).toContainEqual(
expect.objectContaining({
@@ -130,89 +134,143 @@ describe("observeReplaySubagents", () => {
);
});
it("keeps a subagent recorded before spawnDepth existed", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
it("keeps a subagent recorded before spawnDepth existed", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID });
});
it("drops a meta-linked subagent when the parent never declared its Task", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" },
entries: [],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations;
it("drops a meta-linked subagent when the parent never declared its Task", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" },
entries: [],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations;
expect(observations).toEqual([]);
});
it("falls back to the scraped link when there is no meta file", () => {
it("falls back to the scraped link when there is no meta file", async () => {
const parent: ClaudeReplayParentFacts = {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]),
outcomesByToolCallId: new Map(),
};
const [declared, ...rest] = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent,
convertEntry: () => [],
}).observations;
const [declared, ...rest] = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent,
convertEntry: () => [],
})
).observations;
expect(declared).toMatchObject({ id: TOOL_USE_ID, toolCallId: TOOL_USE_ID, title: "Explore" });
expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" });
});
it("drops an unlinkable subagent", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: emptyParent(),
convertEntry: () => [],
}).observations;
it("drops an unlinkable subagent", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: emptyParent(),
convertEntry: () => [],
})
).observations;
expect(observations).toEqual([]);
});
it("drops a terminal subagent the parent has no Task call for", () => {
it("drops a terminal subagent the parent has no Task call for", async () => {
// A legacy grandchild: its toolUseId names a Task call made inside a sibling's transcript, so
// no tool_result for it can ever reach the parent. Left running, it never stops.
const descriptor = applyToStore(
observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations,
(
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations,
);
expect(descriptor).toBeNull();
});
it("drops a terminal skill-spawned subagent that has no link at all", () => {
it("drops a terminal skill-spawned subagent that has no link at all", async () => {
// A /code-review run: no Task tool_use exists to name it, so its sidecar carries only
// agentType and its id appears nowhere in the parent transcript.
const descriptor = applyToStore(
observeReplaySubagents({
(
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { agentType: "general-purpose" },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations,
);
expect(descriptor).toBeNull();
});
it("reports a scraped link's failure as failed", async () => {
const descriptor = applyToStore(
(
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]),
outcomesByToolCallId: new Map(),
},
convertEntry: () => [],
})
).observations,
);
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
});
it("drops a non-terminal linkless subagent", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
@@ -221,111 +279,81 @@ describe("observeReplaySubagents", () => {
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
message: { stop_reason: null },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations,
);
expect(descriptor).toBeNull();
});
it("reports a scraped link's failure as failed", () => {
const descriptor = applyToStore(
observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]),
outcomesByToolCallId: new Map(),
},
convertEntry: () => [],
}).observations,
);
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
});
it("drops a non-terminal linkless subagent", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { agentType: "general-purpose" },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: null },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations;
})
).observations;
expect(observations).toEqual([]);
});
it("finishes a terminal Task child when the parent has not recorded its outcome", () => {
it("finishes a terminal Task child when the parent has not recorded its outcome", async () => {
const descriptor = applyToStore(
observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
}).observations,
(
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
})
).observations,
);
expect(descriptor).toMatchObject({ status: "completed" });
});
it("leaves a subagent with no recorded outcome running", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
}).observations;
it("leaves a subagent with no recorded outcome running", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
})
).observations;
expect(observations.some((o) => o.kind === "status")).toBe(false);
});
it("reports a failed outcome as failed", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(true),
convertEntry: () => [],
}).observations;
it("reports a failed outcome as failed", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(true),
convertEntry: () => [],
})
).observations;
expect(observations.at(-1)).toMatchObject({ kind: "status", status: "failed" });
});
it("carries entry timestamps onto timeline observations", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [{ type: "reasoning", text: "thinking" }],
}).observations;
it("carries entry timestamps onto timeline observations", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [{ type: "reasoning", text: "thinking" }],
})
).observations;
expect(observations.find((observation) => observation.kind === "timeline")).toMatchObject({
kind: "timeline",
@@ -336,18 +364,20 @@ describe("observeReplaySubagents", () => {
});
describe("replay runtime", () => {
it("reads the model and effort off the subagent's own transcript", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
it("reads the model and effort off the subagent's own transcript", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations.find((o) => o.kind === "subtitle")).toEqual({
kind: "subtitle",
@@ -356,49 +386,55 @@ describe("replay runtime", () => {
});
});
it("takes the last observed values when the model changes mid-transcript", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } },
{ type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } },
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
it("takes the last observed values when the model changes mid-transcript", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } },
{ type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } },
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({
subtitle: "general-purpose · Sonnet 5 · Low",
});
});
it("reports nothing when the transcript never names a model or effort", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
it("reports nothing when the transcript never names a model or effort", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations.some((o) => o.kind === "subtitle")).toBe(false);
});
it("passes through a model the manifest does not know", () => {
it("passes through a model the manifest does not know", async () => {
// Claude Code is an Anthropic-compatible client; subagents legitimately report other models.
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", message: { model: "glm-5.1" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", message: { model: "glm-5.1" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({
subtitle: "general-purpose · glm-5.1",
});
@@ -406,20 +442,24 @@ describe("replay runtime", () => {
});
describe("replay usage", () => {
function subtitleOf(entries: ClaudeReplayEntry[]): SubagentObservation | undefined {
return observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations.find((observation) => observation.kind === "subtitle");
async function subtitleOf(
entries: ClaudeReplayEntry[],
): Promise<SubagentObservation | undefined> {
return (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations.find((observation) => observation.kind === "subtitle");
}
it("derives the counters Claude Code itself reports, from a real transcript's shape", () => {
it("derives the counters Claude Code itself reports, from a real transcript's shape", async () => {
// Shape and numbers taken from an on-disk subagent transcript. Claude Code finalizes a
// subagent from the LAST assistant entry's usage, so the earlier, smaller turn must not win
// and must not be added in.
expect(
subtitleOf([
await subtitleOf([
{
type: "assistant",
timestamp: "2026-07-23T23:18:43.023Z",
@@ -462,9 +502,9 @@ describe("replay usage", () => {
});
});
it("formats tokens for a child that only answered", () => {
it("formats tokens for a child that only answered", async () => {
expect(
subtitleOf([
await subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -479,12 +519,12 @@ describe("replay usage", () => {
).toMatchObject({ subtitle: "general-purpose · 16.4k tokens" });
});
it("emits nothing for a transcript with no entries", () => {
expect(subtitleOf([])).toBeUndefined();
it("emits nothing for a transcript with no entries", async () => {
expect(await subtitleOf([])).toBeUndefined();
});
it("keeps duration out of the compact subtitle", () => {
const observation = subtitleOf([
it("keeps duration out of the compact subtitle", async () => {
const observation = await subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -494,8 +534,8 @@ describe("replay usage", () => {
expect(observation).toMatchObject({ subtitle: "general-purpose · 10 tokens" });
});
it("omits totalTokens when no entry carries a usage block", () => {
const observation = subtitleOf([
it("omits totalTokens when no entry carries a usage block", async () => {
const observation = await subtitleOf([
{ type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", message: { content: [] } },
{
type: "assistant",
@@ -506,9 +546,9 @@ describe("replay usage", () => {
expect(observation).toBeUndefined();
});
it("keeps the last usage-bearing entry when the final assistant entry has none", () => {
it("keeps the last usage-bearing entry when the final assistant entry has none", async () => {
expect(
subtitleOf([
await subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -519,34 +559,36 @@ describe("replay usage", () => {
).toMatchObject({ subtitle: "general-purpose · 1k tokens" });
});
it("only reports counters the transcript actually shows", () => {
it("only reports counters the transcript actually shows", async () => {
// A user-only transcript says nothing about cost: no assistant turn, so no tool count either.
const observation = subtitleOf([
const observation = await subtitleOf([
{ type: "user", timestamp: "2026-07-27T16:42:14.698Z", message: { content: "prompt" } },
{ type: "user", timestamp: "2026-07-27T16:42:20.000Z", message: { content: "more" } },
]);
expect(observation).toBeUndefined();
});
it("lands the complete provider-owned subtitle on the descriptor", () => {
it("lands the complete provider-owned subtitle on the descriptor", async () => {
const store = new ProviderSubagentStore();
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
message: { content: [], usage: { input_tokens: 7 } },
},
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
message: { content: [], usage: { input_tokens: 7 } },
},
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
for (const event of foldSubagentObservations(observations)) {
store.apply("parent", "claude", event);
}
@@ -559,7 +601,7 @@ describe("live and replay agree", () => {
// The property the design exists to guarantee: one session, two sources, one descriptor.
// This is inexpressible against the inference implementation, where the paths share no
// vocabulary and each derives identity and status its own way.
it("produces identical descriptor state from the wire and from disk", () => {
it("produces identical descriptor state from the wire and from disk", async () => {
const live = new ClaudeTaskProtocolSource();
const liveObservations = [
...live.observe({
@@ -579,21 +621,23 @@ describe("live and replay agree", () => {
} as unknown as SDKMessage),
];
const replayObservations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: {
toolUseId: TOOL_USE_ID,
agentType: "general-purpose",
description: "Summarize hover and unistyles docs",
const replayObservations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: {
toolUseId: TOOL_USE_ID,
agentType: "general-purpose",
description: "Summarize hover and unistyles docs",
},
entries: [],
},
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
const fromWire = applyToStore(liveObservations);
const fromDisk = applyToStore(replayObservations);
@@ -130,7 +130,7 @@ function readUsage(entry: ClaudeReplayEntry): ClaudeSubagentUsage | null {
export interface ClaudeReplaySubagentInput {
agentId: string;
meta: ClaudeSubagentMeta | null;
entries: Iterable<ClaudeReplayEntry>;
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
/** Task declarations made by this subagent, used to attach its direct children. */
parentFacts?: ClaudeReplayParentFacts;
}
@@ -250,13 +250,13 @@ function observeSubtitle(
};
}
function observeSubagent(
async function observeSubagent(
subagent: ClaudeReplaySubagentInput,
parent: ClaudeReplayParentFacts,
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[],
toolOwners: Map<string, string>,
parentSubagentId?: string,
): SubagentObservation[] {
): Promise<SubagentObservation[]> {
const link = resolveParentLink(subagent, parent);
if (!link) return [];
const toolCall = parent.toolCalls.get(link.toolCallId);
@@ -269,7 +269,7 @@ function observeSubagent(
let childTerminalStatus: ProviderSubagentStatus | null = null;
// Consume each source once for presentation and timeline; keep no raw transcript records.
for (const entry of subagent.entries) {
for await (const entry of subagent.entries) {
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
if (!seenEntry) firstTimestamp = timestamp;
seenEntry = true;
@@ -320,11 +320,11 @@ function recordReplayToolOwners(
}
}
export function observeReplaySubagents(input: {
export async function observeReplaySubagents(input: {
subagents: readonly ClaudeReplaySubagentInput[];
parent: ClaudeReplayParentFacts;
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
}): { observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> } {
}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> }> {
const observations: SubagentObservation[] = [];
const toolOwners = new Map<string, string>();
const unresolved = [...input.subagents].sort(
@@ -346,7 +346,7 @@ export function observeReplaySubagents(input: {
const { ownerId, parent, link } = resolved;
// Only proven descendants may own notifications; ambient sidecars cannot claim them.
for (const observation of observeSubagent(
for (const observation of await observeSubagent(
subagent,
parent,
input.convertEntry,
@@ -35,7 +35,7 @@ function parentEntries(
}
describe("Claude workflow replay", () => {
it("parses the persisted run summary observed from Claude Code 2.1.220", () => {
it("parses the persisted run summary observed from Claude Code 2.1.220", async () => {
expect(
parseClaudeWorkflowRun(
JSON.stringify({
@@ -62,8 +62,8 @@ describe("Claude workflow replay", () => {
});
});
it("rebuilds the same generic completed descriptor through the parent Workflow call", () => {
const observations = observeReplayWorkflows({
it("rebuilds the same generic completed descriptor through the parent Workflow call", async () => {
const observations = await observeReplayWorkflows({
workflows: [
{
runId: RUN_ID,
@@ -91,8 +91,8 @@ describe("Claude workflow replay", () => {
});
});
it("rebuilds a workflow when Claude persists its result as structured text blocks", () => {
const observations = observeReplayWorkflows({
it("rebuilds a workflow when Claude persists its result as structured text blocks", async () => {
const observations = await observeReplayWorkflows({
workflows: [{ runId: RUN_ID, summary: "Structured result workflow", status: "completed" }],
parentEntries: parentEntries([
{
@@ -114,7 +114,7 @@ describe("Claude workflow replay", () => {
});
});
it("replays the workflow result without exposing its script or child prompt", () => {
it("replays the workflow result without exposing its script or child prompt", async () => {
const workflow = parseClaudeWorkflowRun(
JSON.stringify({
runId: RUN_ID,
@@ -128,7 +128,7 @@ describe("Claude workflow replay", () => {
expect(workflow).not.toBeNull();
if (!workflow) throw new Error("expected workflow");
const observations = observeReplayWorkflows({
const observations = await observeReplayWorkflows({
workflows: [workflow],
parentEntries: parentEntries(),
});
@@ -145,9 +145,9 @@ describe("Claude workflow replay", () => {
expect(JSON.stringify(observations)).not.toContain("SECRET CHILD PROMPT");
});
it("does not duplicate a result already present in the child transcript", () => {
it("does not duplicate a result already present in the child transcript", async () => {
const report = "What Paseo is\nA local-first coding-agent environment.";
const observations = observeReplayWorkflows({
const observations = await observeReplayWorkflows({
workflows: [
{ runId: RUN_ID, summary: "Inspect Paseo", status: "completed", result: { report } },
],
@@ -166,8 +166,8 @@ describe("Claude workflow replay", () => {
).toHaveLength(1);
});
it("fails a nonterminal persisted run because its former runtime cannot update it", () => {
const observations = observeReplayWorkflows({
it("fails a nonterminal persisted run because its former runtime cannot update it", async () => {
const observations = await observeReplayWorkflows({
workflows: [{ runId: RUN_ID, summary: "Interrupted workflow", status: "running" }],
parentEntries: parentEntries(),
});
@@ -179,9 +179,9 @@ describe("Claude workflow replay", () => {
});
});
it("drops a run that no parent Workflow tool call declared", () => {
it("drops a run that no parent Workflow tool call declared", async () => {
expect(
observeReplayWorkflows({
await observeReplayWorkflows({
workflows: [{ runId: RUN_ID, status: "completed" }],
parentEntries: [],
}),
@@ -45,14 +45,17 @@ export function parseClaudeWorkflowRun(contents: string): ClaudeWorkflowRun | nu
* provider runtime was recreated, so replay terminalizes it as failed instead of resurrecting a
* row that can never receive another update.
*/
export function observeReplayWorkflows(input: {
export async function observeReplayWorkflows(input: {
workflows: readonly ClaudeWorkflowRun[];
parentEntries: Iterable<ClaudeWorkflowParentEntry>;
parentEntries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>;
/** Repeatable entries in chronological order, supplied by the transcript reader. */
entriesByRunId?: ReadonlyMap<string, Iterable<ClaudeReplayEntry>>;
entriesByRunId?: ReadonlyMap<
string,
Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>
>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
}): SubagentObservation[] {
const toolCallIdByRunId = readWorkflowLinks(input.parentEntries);
}): Promise<SubagentObservation[]> {
const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries);
const observations: SubagentObservation[] = [];
for (const workflow of input.workflows) {
@@ -79,7 +82,7 @@ export function observeReplayWorkflows(input: {
});
}
for (const observation of observeWorkflowTimeline({
for (const observation of await observeWorkflowTimeline({
id,
result: workflow.result,
entries: input.entriesByRunId?.get(workflow.runId) ?? [],
@@ -115,17 +118,17 @@ export function observeReplayWorkflows(input: {
return observations;
}
function observeWorkflowTimeline(input: {
async function observeWorkflowTimeline(input: {
id: string;
result: unknown;
entries: Iterable<ClaudeReplayEntry>;
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
finishedAt?: string;
}): SubagentObservation[] {
}): Promise<SubagentObservation[]> {
const observations: SubagentObservation[] = [];
const replayedAssistantText = new Set<string>();
for (const entry of input.entries) {
for await (const entry of input.entries) {
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
const items = input.convertEntry?.(entry) ?? [];
for (const item of items) {
@@ -150,11 +153,13 @@ function observeWorkflowTimeline(input: {
return observations;
}
function readWorkflowLinks(entries: Iterable<ClaudeWorkflowParentEntry>): Map<string, string> {
async function readWorkflowLinks(
entries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>,
): Promise<Map<string, string>> {
const workflowToolCallIds = new Set<string>();
const toolCallIdByRunId = new Map<string, string>();
for (const entry of entries) {
for await (const entry of entries) {
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -165,7 +170,7 @@ function readWorkflowLinks(entries: Iterable<ClaudeWorkflowParentEntry>): Map<st
}
}
for (const entry of entries) {
for await (const entry of entries) {
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -221,9 +221,11 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
/** Normalize notification identity for one transcript traversal. Queue bookkeeping and delivery
* are separate records of the same occurrence; each source counts repeated identical bodies
* independently so the second event is not merged with the first. */
export function* assignTaskNotificationReplayIds<T>(entries: Iterable<T>): Generator<T> {
export async function* assignTaskNotificationReplayIds<T>(
entries: AsyncIterable<T>,
): AsyncGenerator<T> {
const occurrences = new Map<string, Map<string, number>>();
for (const entry of entries) {
for await (const entry of entries) {
const record = TaskNotificationHistoryRecordSchema.safeParse(entry);
if (!record.success) {
yield entry;
@@ -1,5 +1,6 @@
import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js";
import fs from "node:fs";
import fs, { type FileHandle } from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import path from "node:path";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import {
@@ -24,11 +25,11 @@ interface RecordLocation {
}
/** Chunk in bytes so UTF-8 and multi-chunk records are decoded once, at a line boundary. */
function* locatedRecords(
async function* locatedRecords(
file: string,
fd: number,
): Generator<{ entry: TranscriptEntry; location: RecordLocation }> {
const stat = fs.fstatSync(fd);
fd: FileHandle,
): AsyncGenerator<{ entry: TranscriptEntry; location: RecordLocation }> {
const stat = await fd.stat();
// Opening a directory succeeds on some platforms; it must never look like empty history.
if (stat.isDirectory()) {
throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${file}'`), {
@@ -43,7 +44,7 @@ function* locatedRecords(
let length = 0;
while (remaining > 0) {
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining));
const count = fs.readSync(fd, buffer, 0, buffer.length, null);
const { bytesRead: count } = await fd.read(buffer, 0, buffer.length, null);
if (count === 0) break;
remaining -= count;
let start = 0;
@@ -64,6 +65,8 @@ function* locatedRecords(
if (entry) yield { entry, location };
start = end + 1;
}
// Bound parsing work as well as IO; microtasks alone do not service timers or sockets.
await setImmediate();
if (start < count) {
pieces.push(buffer.subarray(start, count));
length += count - start;
@@ -85,20 +88,25 @@ function* locatedRecords(
/** Indexed sources parse only their own records using descriptors owned by this replay. */
function indexedRecords(
locations: readonly RecordLocation[],
descriptors: ReadonlyMap<string, number>,
): Iterable<TranscriptEntry> {
descriptors: ReadonlyMap<string, FileHandle>,
): AsyncIterable<TranscriptEntry> {
return {
*[Symbol.iterator]() {
async *[Symbol.asyncIterator]() {
yield* assignTaskNotificationReplayIds(readEntries());
},
};
function* readEntries(): Generator<TranscriptEntry> {
async function* readEntries(): AsyncGenerator<TranscriptEntry> {
for (const location of locations) {
const fd = descriptors.get(location.file)!;
const buffer = Buffer.allocUnsafe(location.length);
let read = 0;
while (read < buffer.length) {
const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read);
const { bytesRead: count } = await fd.read(
buffer,
read,
buffer.length - read,
location.offset + read,
);
if (count === 0)
throw new Error(`Claude transcript was truncated during replay: ${location.file}`);
read += count;
@@ -127,9 +135,9 @@ function parseRecord(line: string): TranscriptEntry | null {
}
}
function readOptionalFile(file: string): string | null {
async function readOptionalFile(file: string): Promise<string | null> {
try {
return fs.readFileSync(file, "utf8");
return await fs.readFile(file, "utf8");
} catch {
return null;
}
@@ -138,31 +146,40 @@ function readOptionalFile(file: string): string | null {
/** Discover sources without retaining transcript contents. Unrelated sidechains stay excluded
* by the replay ownership resolver; workflow children retain their separate run ownership. */
export interface ClaudeReplayHistory {
parentEntries: Iterable<TranscriptEntry>;
parentEntries: AsyncIterable<TranscriptEntry>;
subagents: {
agentId: string;
meta: ClaudeSubagentMeta | null;
entries: Iterable<TranscriptEntry>;
entries: AsyncIterable<TranscriptEntry>;
}[];
workflows: ClaudeWorkflowRun[];
workflowEntriesByRunId: Map<string, Iterable<TranscriptEntry>>;
workflowEntriesByRunId: Map<string, AsyncIterable<TranscriptEntry>>;
}
/** Own descriptors through every pass so unlink or rename cannot invalidate discovered history.
* Sources must be consumed before the callback returns. No transcript copies are needed. */
export function withClaudeReplayHistory<T>(
export async function withClaudeReplayHistory<T>(
historyPath: string,
consume: (history: ClaudeReplayHistory) => T,
): T {
const descriptors = new Map<string, number>();
consume: (history: ClaudeReplayHistory) => Promise<T>,
): Promise<T> {
const descriptors = new Map<string, FileHandle>();
try {
return consume(indexHistory(historyPath, descriptors));
return await consume(await indexHistory(historyPath, descriptors));
} finally {
for (const fd of descriptors.values()) fs.closeSync(fd);
await closeReplaySources(descriptors.values());
}
}
function indexHistory(historyPath: string, descriptors: Map<string, number>): ClaudeReplayHistory {
async function closeReplaySources(descriptors: Iterable<FileHandle>): Promise<void> {
const results = await Promise.allSettled([...descriptors].map((fd) => fd.close()));
const failure = results.find((result) => result.status === "rejected");
if (failure) throw failure.reason;
}
async function indexHistory(
historyPath: string,
descriptors: Map<string, FileHandle>,
): Promise<ClaudeReplayHistory> {
const sessionDirectory = path.join(
path.dirname(historyPath),
path.basename(historyPath, ".jsonl"),
@@ -174,8 +191,7 @@ function indexHistory(historyPath: string, descriptors: Map<string, number>): Cl
const directories = [sidechainDirectory];
while (directories.length) {
const directory = directories.pop()!;
if (!fs.existsSync(directory)) continue;
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
for (const entry of await optionalDirectory(directory)) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) {
directories.push(file);
@@ -194,23 +210,23 @@ function indexHistory(historyPath: string, descriptors: Map<string, number>): Cl
}
const agentId = /^agent-(.+)\.meta\.json$/.exec(entry.name)?.[1];
if (!agentId) continue;
const contents = readOptionalFile(file);
const contents = await readOptionalFile(file);
const meta = contents === null ? null : parseClaudeSubagentMeta(contents);
if (meta) metaByAgentId.set(agentId, meta);
}
}
const parentLocations: RecordLocation[] = [];
const locationsByAgentId = new Map<string, RecordLocation[]>();
function scan(
async function scan(
file: string,
visit: (entry: TranscriptEntry, location: RecordLocation) => void,
): void {
const fd = fs.openSync(file, "r");
): Promise<void> {
const fd = await fs.open(file, "r");
descriptors.set(file, fd);
for (const { entry, location } of locatedRecords(file, fd)) visit(entry, location);
for await (const { entry, location } of locatedRecords(file, fd)) visit(entry, location);
}
for (const file of sidechainFiles) {
scan(file, (entry, location) => {
await scan(file, (entry, location) => {
if (file === historyPath && entry.isSidechain !== true) parentLocations.push(location);
if (entry.isSidechain !== true || typeof entry.agentId !== "string") return;
const locations = locationsByAgentId.get(entry.agentId) ?? [];
@@ -218,11 +234,11 @@ function indexHistory(historyPath: string, descriptors: Map<string, number>): Cl
locationsByAgentId.set(entry.agentId, locations);
});
}
const workflowEntriesByRunId = new Map<string, Iterable<TranscriptEntry>>();
const workflowEntriesByRunId = new Map<string, AsyncIterable<TranscriptEntry>>();
for (const [runId, files] of workflowFiles) {
const locations: RecordLocation[] = [];
for (const file of files)
scan(file, (entry, location) => {
await scan(file, (entry, location) => {
if (entry.type !== "user" || isToolResult(entry)) locations.push(location);
});
locations.sort((a, b) => {
@@ -240,21 +256,28 @@ function indexHistory(historyPath: string, descriptors: Map<string, number>): Cl
meta: metaByAgentId.get(agentId) ?? null,
entries: indexedRecords(locations, descriptors),
})),
workflows: readWorkflows(sessionDirectory),
workflows: await readWorkflows(sessionDirectory),
workflowEntriesByRunId,
};
}
function readWorkflows(sessionDirectory: string): ClaudeWorkflowRun[] {
async function readWorkflows(sessionDirectory: string): Promise<ClaudeWorkflowRun[]> {
const workflows: ClaudeWorkflowRun[] = [];
const workflowDirectory = path.join(sessionDirectory, "workflows");
if (fs.existsSync(workflowDirectory)) {
for (const entry of fs.readdirSync(workflowDirectory, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
const contents = readOptionalFile(path.join(workflowDirectory, entry.name));
const workflow = contents === null ? null : parseClaudeWorkflowRun(contents);
if (workflow) workflows.push(workflow);
}
for (const entry of await optionalDirectory(workflowDirectory)) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
const contents = await readOptionalFile(path.join(workflowDirectory, entry.name));
const workflow = contents === null ? null : parseClaudeWorkflowRun(contents);
if (workflow) workflows.push(workflow);
}
return workflows;
}
async function optionalDirectory(directory: string) {
try {
return await fs.readdir(directory, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
}
@@ -183,3 +183,29 @@ describe("Codex app-server transport", () => {
codex.child.stdin.end();
});
});
describe("Codex stdout byte admission", () => {
test("fails pending requests and the session before joining an oversized line", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const terminations: Error[] = [];
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
const request = client.request("thread/resume", {}, 1000).catch((error: Error) => error);
const chunk = Buffer.alloc(64 * 1024, "x");
child.stdout.write('{"id":1,"result":"');
for (let index = 0; index < 2048; index++) child.stdout.write(chunk);
const error = await request;
expect(error).toMatchObject({
name: "ProviderStreamLimitError",
code: "PROVIDER_STREAM_LINE_TOO_LARGE",
limitBytes: 128 * 1024 * 1024,
});
expect(terminations).toEqual([error]);
expect(client.hasPendingRequests()).toBe(false);
child.stdout.write('"}\n{"id":1,"result":"late"}\n');
await expect(client.request("model/list")).rejects.toThrow("closed");
await client.dispose();
child.stdout.end();
child.stderr.end();
});
});
@@ -1,10 +1,15 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { StringDecoder } from "node:string_decoder";
import type { Logger } from "pino";
import { z } from "zod";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import { ProviderLineReader, ProviderStreamLimitError } from "../jsonl-frame-decoder.js";
// A measured long-thread resume was 19.75 MB; preserve the existing 32 MiB fixture
// with 4x margin for longer threads. This is provider stdout, not the client protocol.
export const CODEX_PROVIDER_LINE_BYTES = 128 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
const APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
@@ -167,39 +172,6 @@ function readProviderTurnId(params: unknown): string | undefined {
return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined;
}
/**
* Splits stdout into lines on "\n" only. `node:readline` additionally treats U+2028 and U+2029
* as line terminators, but Codex emits those characters raw inside JSON strings, so readline
* would cut a single JSON-RPC message into pieces that fail to parse.
*/
function createNewlineLineReader(
input: NodeJS.ReadableStream,
onLine: (line: string) => void,
): { close(): void } {
const decoder = new StringDecoder("utf8");
// Text after the last "\n", kept as parts and joined once its line ends, so a large line
// arriving in many chunks is scanned and copied once instead of once per chunk.
let unterminated: string[] = [];
const onData = (chunk: Buffer | string): void => {
const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
let lineStart = 0;
let index = text.indexOf("\n");
while (index !== -1) {
unterminated.push(text.slice(lineStart, index));
const line = unterminated.join("");
unterminated = [];
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
lineStart = index + 1;
index = text.indexOf("\n", lineStart);
}
if (lineStart < text.length) {
unterminated.push(text.slice(lineStart));
}
};
input.on("data", onData);
return { close: () => void input.off("data", onData) };
}
export class CodexAppServerClient {
private readonly stdoutLineReader: { close(): void };
private activeProviderRequests = 0;
@@ -216,11 +188,24 @@ export class CodexAppServerClient {
private readonly logger: Logger,
private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}),
) {
this.stdoutLineReader = createNewlineLineReader(child.stdout, (line) => {
void this.handleLine(line).catch((error) => {
this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line");
});
});
// Split only on LF: U+2028/U+2029 are valid raw characters inside Codex JSON.
const reader = new ProviderLineReader(
CODEX_PROVIDER_LINE_BYTES,
(line) => {
void this.handleLine(line).catch((error) => {
this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line");
});
},
(error) => this.handleStreamLimit(error),
);
const onData = (chunk: Buffer | string) => reader.write(chunk);
child.stdout.on("data", onData);
this.stdoutLineReader = {
close: () => {
reader.close();
child.stdout.off("data", onData);
},
};
child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
@@ -318,6 +303,15 @@ export class CodexAppServerClient {
}
}
private handleStreamLimit(failure: ProviderStreamLimitError): void {
// A rejected response/notification cannot be replayed safely. Fail the session,
// including live turns with no pending RPC, rather than dropping a stream unit.
this.handleUnexpectedTermination(failure);
void this.dispose().catch((err) => {
this.logger.warn({ err }, "Failed to terminate oversized Codex stdout producer");
});
}
private handleUnexpectedTermination(error: Error): void {
if (this.disposed) {
return;
@@ -1,3 +1,5 @@
import { performance } from "node:perf_hooks";
import { setTimeout as delay } from "node:timers/promises";
import { describe, expect, it } from "vitest";
import { JsonlFrameDecoder, type JsonlFrameProblem } from "./jsonl-frame-decoder.js";
@@ -36,6 +38,16 @@ describe("JsonlFrameDecoder", () => {
expect(problems).toEqual([]);
});
it("preserves UTF-8 characters split across raw stdout buffers", () => {
const { decoder, frames, problems } = decoderHarness();
const bytes = Buffer.from('{"type":"notice","text":"é🙂"}\n');
const split = bytes.indexOf(Buffer.from("🙂")) + 1;
decoder.write(bytes.subarray(0, split));
decoder.write(bytes.subarray(split));
expect(frames).toEqual([{ type: "notice", text: "é🙂" }]);
expect(problems).toEqual([]);
});
it("reassembles a large ordered v2 frame", () => {
const { decoder, frames, problems } = decoderHarness();
const frame = { type: "models", value: "x".repeat(1024 * 1024) };
@@ -55,3 +67,57 @@ describe("JsonlFrameDecoder", () => {
expect(problems).toEqual(["out-of-order-chunk"]);
});
});
describe("provider line admission", () => {
it("keeps the loop responsive while accumulating a fragmented provider response", async () => {
const { decoder, frames, problems } = decoderHarness();
const part = "x".repeat(512);
let last = performance.now();
let longestGap = 0;
const probe = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - last);
last = now;
}, 1);
try {
await delay(5);
decoder.write('{"type":"notice","text":"');
// 1 MiB is enough to expose the former rescan of every accumulated prefix.
for (let index = 0; index < 2048; index++) decoder.write(part);
decoder.write('"}\n');
await delay(5);
expect(frames).toEqual([{ type: "notice", text: part.repeat(2048) }]);
expect(problems).toEqual([]);
expect(longestGap).toBeLessThan(50);
} finally {
clearInterval(probe);
}
});
it("rejects an unterminated over-cap UTF-8 line and stops accepting frames", () => {
const frames: Record<string, unknown>[] = [];
const failures: Array<{ problem: string; detail: unknown }> = [];
const decoder = new JsonlFrameDecoder({
frame: (frame) => frames.push(frame),
problem: (problem, detail) => failures.push({ problem, detail }),
});
// 64 MiB of UTF-8, only 32 Mi UTF-16 code units. No newline or JSON parse needed.
const chunk = "é".repeat(32 * 1024);
for (let index = 0; index < 1024; index++) decoder.write(chunk);
expect(failures).toEqual([]);
decoder.write("é");
expect(failures).toEqual([
{
problem: "line-too-large",
detail: expect.objectContaining({
name: "ProviderStreamLimitError",
code: "PROVIDER_STREAM_LINE_TOO_LARGE",
limitBytes: 64 * 1024 * 1024,
}),
},
]);
decoder.write('\n{"type":"ready"}\n');
expect(frames).toEqual([]);
expect(failures).toHaveLength(1);
});
});
@@ -1,3 +1,60 @@
// Provider-process stdout only; these are not app/daemon wire limits.
export const JSONL_PROVIDER_LINE_BYTES = 64 * 1024 * 1024;
export class ProviderStreamLimitError extends Error {
readonly code = "PROVIDER_STREAM_LINE_TOO_LARGE";
constructor(readonly limitBytes: number) {
super(
`Provider stdout line exceeds ${limitBytes} bytes. Start a new thread or reduce the provider response size.`,
);
this.name = "ProviderStreamLimitError";
}
}
/** Admits raw UTF-8 bytes before retaining, decoding or joining a complete line. */
export class ProviderLineReader {
private parts: Buffer[] = [];
private bytes = 0;
private stopped = false;
constructor(
private readonly limitBytes: number,
private readonly onLine: (line: string) => void,
private readonly onLimit: (error: ProviderStreamLimitError) => void,
) {}
write(chunk: Buffer | string): void {
if (this.stopped) return;
const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
let start = 0;
while (start < buffer.byteLength && !this.stopped) {
const newline = buffer.indexOf(10, start);
const end = newline === -1 ? buffer.byteLength : newline;
const length = end - start;
if (length > this.limitBytes - this.bytes) {
this.close();
this.onLimit(new ProviderStreamLimitError(this.limitBytes));
return;
}
if (length) this.parts.push(buffer.subarray(start, end));
this.bytes += length;
if (newline === -1) return;
const line = Buffer.concat(this.parts, this.bytes).toString("utf8");
this.parts = [];
this.bytes = 0;
this.onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
start = newline + 1;
}
}
close(): void {
this.stopped = true;
this.parts = [];
this.bytes = 0;
}
}
const V2_FRAME_BYTES = 1024 * 1024;
const V2_REASSEMBLED_BYTES = 64 * 1024 * 1024;
const V2_CHUNK_BYTES = 256 * 1024;
@@ -5,6 +62,7 @@ const V2_BASE64_CHARS = Math.ceil(V2_CHUNK_BYTES / 3) * 4;
const V2_MAX_CHUNKS = Math.ceil(V2_REASSEMBLED_BYTES / V2_CHUNK_BYTES);
export type JsonlFrameProblem =
| "line-too-large"
| "invalid-json"
| "invalid-chunk"
| "invalid-base64"
@@ -78,7 +136,7 @@ function chunkBytes(data: string): Buffer | null {
}
export class JsonlFrameDecoder {
private lineBuffer = "";
private readonly lines: ProviderLineReader;
private assembly: ChunkAssembly | null = null;
constructor(
@@ -86,17 +144,18 @@ export class JsonlFrameDecoder {
frame(message: Record<string, unknown>): void;
problem(problem: JsonlFrameProblem, detail?: unknown): void;
},
) {}
) {
this.lines = new ProviderLineReader(
JSONL_PROVIDER_LINE_BYTES,
(line) => {
if (line.trim()) this.consumeLine(line);
},
(error) => this.drop("line-too-large", error),
);
}
write(text: string): void {
this.lineBuffer += text;
let newline = this.lineBuffer.indexOf("\n");
while (newline !== -1) {
const line = this.lineBuffer.slice(0, newline).replace(/\r$/, "");
this.lineBuffer = this.lineBuffer.slice(newline + 1);
if (line.trim()) this.consumeLine(line);
newline = this.lineBuffer.indexOf("\n");
}
write(chunk: Buffer | string): void {
this.lines.write(chunk);
}
private consumeLine(line: string): void {
@@ -471,3 +471,25 @@ describe("JsonlRpcProcess", () => {
}
});
});
describe("JSONL stdout byte admission", () => {
test("fails pending requests and publishes the typed failure to live-turn consumers", async () => {
const child = createInMemoryChildProcess();
const process = startProcess({ child });
const exit = new Promise<JsonlRpcExit>((resolve) => process.onExit(resolve));
const request = process.request({ type: "hang" }, 1000).catch((error: Error) => error);
const chunk = Buffer.alloc(64 * 1024, "x");
child.stdout.write('{"type":"notice","text":"');
for (let index = 0; index < 1024; index++) child.stdout.write(chunk);
const error = await request;
expect(error).toMatchObject({
name: "ProviderStreamLimitError",
code: "PROVIDER_STREAM_LINE_TOO_LARGE",
limitBytes: 64 * 1024 * 1024,
});
expect((await exit).error).toBe(error);
await process.close();
child.stdout.end();
child.stderr.end();
});
});
@@ -3,7 +3,7 @@ import type { Logger } from "pino";
import { spawnProcess } from "../../../utils/spawn.js";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
import { JsonlFrameDecoder } from "./jsonl-frame-decoder.js";
import { JsonlFrameDecoder, ProviderStreamLimitError } from "./jsonl-frame-decoder.js";
export { supportsJsonlRpcProtocolV2 } from "./jsonl-frame-decoder.js";
/** Default wall-clock timeout for control-plane / short RPC calls. */
@@ -81,6 +81,7 @@ export class JsonlRpcProcess {
private stderrBuffer = "";
private nextRequestId = 1;
private disposed = false;
private streamFailure: ProviderStreamLimitError | null = null;
private exited = false;
private closing: Promise<void> | null = null;
private readonly frameDecoder: JsonlFrameDecoder;
@@ -90,6 +91,14 @@ export class JsonlRpcProcess {
this.frameDecoder = new JsonlFrameDecoder({
frame: (message) => this.dispatchFrame(message),
problem: (problem, detail) => {
if (problem === "line-too-large" && detail instanceof ProviderStreamLimitError) {
// Stop the producer: silently skipping a response or turn event loses state.
this.streamFailure = detail;
void this.close(detail).catch((err) => {
this.options.logger.warn({ err }, `Failed to close ${this.diagnosticName}`);
});
return;
}
this.options.logger.warn(
{ problem, detail },
`Ignoring invalid ${this.diagnosticName} frame`,
@@ -98,7 +107,7 @@ export class JsonlRpcProcess {
});
this.child = (options.spawn ?? spawnJsonlRpcProcess)(options.launch);
this.child.stdout.on("data", (chunk) => {
this.handleStdoutChunk(chunk.toString());
this.handleStdoutChunk(chunk);
});
this.child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
@@ -114,9 +123,11 @@ export class JsonlRpcProcess {
});
this.child.on("exit", (code, signal) => {
this.exited = true;
const error = new Error(
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
const error =
this.streamFailure ??
new Error(
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
const exit = { code, signal, error };
for (const subscriber of this.exitSubscribers) {
subscriber(exit);
@@ -254,7 +265,7 @@ export class JsonlRpcProcess {
}
}
private handleStdoutChunk(chunk: string): void {
private handleStdoutChunk(chunk: Buffer | string): void {
this.frameDecoder.write(chunk);
}
@@ -1133,6 +1133,17 @@ describe("OMP agent client and session", () => {
messageId: "assistant-history",
},
]);
await expect(
omp.runPrompt("continue after resume", "resumed follow-up complete"),
).resolves.toMatchObject({
finalText: "resumed follow-up complete",
});
expect(omp.timeline()).toContainEqual(
expect.objectContaining({
type: "assistant_message",
text: "resumed follow-up complete",
}),
);
expect(omp.usageSession()).toMatchObject({ provider: "omp", sessionKey: expect.any(String) });
await omp.close();
expect(omp.usageSession()).toBeNull();
@@ -1,4 +1,5 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
@@ -8,7 +9,7 @@ import { streamOmpCoreHistory, type OmpCapturedUserMessageEntry } from "./messag
import type { OmpAgentMessage } from "./rpc-types.js";
import { FakeOmp } from "./test-utils/fake-omp.js";
import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js";
import { readOmpHistoryTodoState, streamOmpHistory } from "./history.js";
import { readActiveOmpEntryChain, readOmpHistoryTodoState, streamOmpHistory } from "./history.js";
async function collectHistory(
messages: OmpAgentMessage[],
@@ -1245,3 +1246,53 @@ describe("OMP history mapper", () => {
);
});
});
test("indexes out-of-order branches, duplicate IDs and cycles before selecting history", async () => {
const directory = await mkdtemp(join(tmpdir(), "omp-history-branches-"));
const sessionFile = join(directory, "session.jsonl");
const root = { id: "root", parentId: null };
const earlyLeaf = {
id: "early",
parentId: "root",
message: { role: "user", content: "你好🌍".repeat(15000) },
};
const branch = { id: "branch", parentId: "root" };
const active = { id: "active", parentId: "branch" };
const duplicate = {
id: "branch",
parentId: "root",
message: { role: "user", content: "replacement" },
};
try {
await writeFile(
sessionFile,
[earlyLeaf, active, root, branch, duplicate]
.map((entry) => JSON.stringify(entry))
.join("\n") + '\n{"id":',
);
// The final record is a branch ancestor, not the active leaf. Latest duplicate wins
// when following parents; the invalid tail contributes neither a leaf nor a parent.
await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual([root, duplicate, active]);
await expect(readActiveOmpEntryChain(sessionFile, "early")).resolves.toEqual([root, earlyLeaf]);
await expect(readActiveOmpEntryChain(sessionFile, "missing")).resolves.toEqual([
root,
duplicate,
active,
]);
const cycle = [
{ id: "a", parentId: "b" },
{ id: "b", parentId: "a" },
];
await writeFile(sessionFile, cycle.map((entry) => JSON.stringify(entry)).join("\n"));
await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual(cycle);
await writeFile(sessionFile, "null\n{}\ninvalid\n");
await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual([]);
await expect(readActiveOmpEntryChain(directory)).rejects.toMatchObject({ code: "EISDIR" });
await expect(readActiveOmpEntryChain(join(directory, "missing"))).rejects.toMatchObject({
code: "ENOENT",
});
await expect(readOmpHistoryTodoState(join(directory, "missing"))).resolves.toBeNull();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,104 @@
import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
import { mkdtemp, open, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
import { setTimeout as delay } from "node:timers/promises";
import { expect, test } from "vitest";
import { createTestPaseoDaemon } from "../../../test-utils/paseo-daemon.js";
import { DaemonClient } from "../../../test-utils/daemon-client.js";
import { readActiveOmpEntryChain } from "./history.js";
test("replays selected OMP history without blocking the event loop", async () => {
const directory = await mkdtemp(join(tmpdir(), "omp-history-budget-"));
const sessionFile = join(directory, "session.jsonl");
const started = performance.now();
try {
const daemon = await createTestPaseoDaemon({
// This regression exercises file replay and daemon requests, not executable discovery.
providerOverrides: Object.fromEntries(
BUILTIN_PROVIDER_IDS.map((id) => [id, { enabled: false }]),
),
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.13.2",
});
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
await client.getDaemonStatus();
const file = await open(sessionFile, "w");
// Structured message blocks expose document-sized parsing at a modest byte size.
// Replay an earlier active entry so inactive payload retention cannot drive the probe.
const text = Array.from({ length: 64 }, () => ({ type: "text", text: "x" }));
let bytes = 0;
let count = 0;
try {
while (bytes < 28 * 1024 * 1024) {
const line =
JSON.stringify({
type: "message",
id: `row-${count}`,
parentId: count ? `row-${count - 1}` : null,
message: { role: "user", content: text },
}) + "\n";
bytes += Buffer.byteLength(line);
await file.write(line);
count++;
}
} finally {
await file.close();
}
let last = performance.now();
let longestGap = 0;
const probe = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - last);
last = now;
}, 5);
try {
const replayState = { finished: false };
let repliesDuringReplay = 0;
const replay = readActiveOmpEntryChain(sessionFile, "row-0").finally(() => {
replayState.finished = true;
});
// A real WebSocket RPC served by the isolated daemon on the same event loop.
const requests = (async () => {
while (!replayState.finished) {
await client.getDaemonStatus();
if (!replayState.finished) repliesDuringReplay++;
await delay(10);
}
})();
const [entries] = await Promise.all([replay, requests]);
await delay(10);
console.log(
JSON.stringify({
bytes,
count,
longestGap,
repliesDuringReplay,
generationAndLoadMs: performance.now() - started,
}),
);
expect(entries).toEqual([
{
type: "message",
id: "row-0",
parentId: null,
message: { role: "user", content: text },
},
]);
expect(longestGap).toBeLessThan(50);
expect(repliesDuringReplay).toBeGreaterThan(0);
} finally {
clearInterval(probe);
}
} finally {
await client.close().finally(() => daemon.close());
}
} finally {
await rm(directory, { recursive: true, force: true });
}
}, 30000);
@@ -1,4 +1,5 @@
import { readFile } from "node:fs/promises";
import { open, type FileHandle } from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import { basename, extname, join } from "node:path";
import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
@@ -96,7 +97,9 @@ export async function* streamOmpHistory(input: {
const messages: OmpAgentMessage[] = [];
const replayEntries: OmpReplayEntry[] = [];
const userEntries: OmpCapturedUserMessageEntry[] = [];
let prepared = 0;
for (const entry of entries) {
if (++prepared % 32 === 0) await setImmediate();
if (entry.type === "compaction") {
replayEntries.push({ entry, item: mapCompactionEntry(entry) });
continue;
@@ -115,7 +118,9 @@ export async function* streamOmpHistory(input: {
OMP_HISTORY_MAPPER_HOOKS,
input.bridgedTools,
);
let replayed = 0;
for (const replayEntry of replayEntries) {
if (++replayed % 32 === 0) await setImmediate();
const timestamp = normalizeProviderReplayTimestamp(replayEntry.entry.timestamp);
const events: AgentStreamEvent[] =
"item" in replayEntry
@@ -329,34 +334,139 @@ function stripExtension(filePath: string): string {
return extension ? filePath.slice(0, -extension.length) : filePath;
}
export async function readActiveOmpEntryChain(
sessionFile: string,
activeEntryId?: string,
): Promise<OmpSessionEntry[]> {
const content = await readFile(sessionFile, "utf8");
const entries = content.split("\n").flatMap((line) => {
if (!line.trim()) return [];
try {
const value = JSON.parse(line) as OmpSessionEntry;
return value && typeof value === "object" && typeof value.id === "string" ? [value] : [];
} catch {
return [];
interface OmpEntryLocation {
id: string;
parentId?: string | null;
offset: number;
}
// Yield after each bounded IO chunk, including chunks containing only tiny or invalid records.
// Keep bytes until a complete line so multi-byte UTF-8 characters survive chunk boundaries.
async function* readOmpLines(
file: FileHandle,
size: number,
): AsyncGenerator<{
line: Buffer;
offset: number;
}> {
const chunkSize = 64 * 1024;
let fragments: Buffer[] = [];
let lineOffset = 0;
for (let position = 0; position < size; ) {
const chunk = Buffer.allocUnsafe(Math.min(chunkSize, size - position));
const { bytesRead } = await file.read(chunk, 0, chunk.length, position);
if (!bytesRead) break;
let start = 0;
for (let index = 0; index < bytesRead; index++) {
if (chunk[index] !== 10) continue;
const tail = chunk.subarray(start, index);
const line = fragments.length ? Buffer.concat([...fragments, tail]) : tail;
yield { line, offset: lineOffset };
fragments = [];
start = index + 1;
lineOffset = position + start;
}
});
if (entries.length === 0) return [];
const byId = new Map(entries.map((entry) => [entry.id!, entry]));
const parentIds = new Set(entries.flatMap((entry) => (entry.parentId ? [entry.parentId] : [])));
const leaves = entries.filter((entry) => !parentIds.has(entry.id!));
let current: OmpSessionEntry | undefined =
(activeEntryId ? byId.get(activeEntryId) : undefined) ?? leaves.at(-1) ?? entries.at(-1);
const chain: OmpSessionEntry[] = [];
if (start < bytesRead) fragments.push(chunk.subarray(start, bytesRead));
position += bytesRead;
await setImmediate();
}
if (fragments.length) yield { line: Buffer.concat(fragments), offset: lineOffset };
}
function parseOmpEntry(line: Buffer): OmpSessionEntry | null {
try {
const value: unknown = JSON.parse(line.toString("utf8"));
return value && typeof value === "object" && typeof Reflect.get(value, "id") === "string"
? (value as OmpSessionEntry)
: null;
} catch {
// OMP can leave a partial last record; malformed records have always been ignored.
return null;
}
}
async function selectOmpChain(
locations: readonly OmpEntryLocation[],
byId: ReadonlyMap<string, OmpEntryLocation>,
parentIds: ReadonlySet<string>,
activeEntryId?: string,
): Promise<OmpEntryLocation[]> {
let current = activeEntryId ? byId.get(activeEntryId) : undefined;
if (!current) {
for (let index = locations.length - 1; index >= 0; index--) {
const location = locations[index]!;
if (!parentIds.has(location.id)) {
current = location;
break;
}
if (index % 256 === 0) await setImmediate();
}
current ??= locations.at(-1);
}
const chain: OmpEntryLocation[] = [];
const seen = new Set<string>();
while (current?.id && !seen.has(current.id)) {
chain.push(current);
seen.add(current.id);
current = current.parentId ? byId.get(current.parentId) : undefined;
if (chain.length % 256 === 0) await setImmediate();
}
return chain;
}
export async function readActiveOmpEntryChain(
sessionFile: string,
activeEntryId?: string,
): Promise<OmpSessionEntry[]> {
const file = await open(sessionFile, "r");
try {
const stat = await file.stat();
if (stat.isDirectory()) {
throw Object.assign(
new Error(`EISDIR: illegal operation on a directory, read '${sessionFile}'`),
{
code: "EISDIR",
},
);
}
// One descriptor and a fixed byte boundary exclude appends made while replay is yielding.
// Retain graph metadata only: abandoned branches must not retain their message payloads.
const locations: OmpEntryLocation[] = [];
const byId = new Map<string, OmpEntryLocation>();
const parentIds = new Set<string>();
for await (const { line, offset } of readOmpLines(file, stat.size)) {
const entry = parseOmpEntry(line);
if (!entry) continue;
const location = { id: entry.id!, parentId: entry.parentId, offset };
locations.push(location);
byId.set(location.id, location);
if (location.parentId) parentIds.add(location.parentId);
}
const chain = await selectOmpChain(locations, byId, parentIds, activeEntryId);
if (chain.length === 0) return [];
const selected = new Map<number, number>();
for (let index = 0; index < chain.length; index++) {
selected.set(chain[index]!.offset, chain.length - index - 1);
if (index % 256 === 0) await setImmediate();
}
const entries: OmpSessionEntry[] = [];
for await (const { line, offset } of readOmpLines(file, stat.size)) {
const index = selected.get(offset);
if (index === undefined) continue;
const entry = parseOmpEntry(line);
// Truncation in place invalidates the indexed snapshot; never publish a partial chain.
if (!entry || entry.id !== chain[chain.length - index - 1]!.id) {
throw new Error(`OMP transcript changed during replay: ${sessionFile}`);
}
entries[index] = entry;
selected.delete(offset);
if (selected.size === 0) break;
}
if (selected.size) throw new Error(`OMP transcript changed during replay: ${sessionFile}`);
return entries;
} finally {
await file.close();
}
return chain.toReversed();
}
function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null {
@@ -56,6 +56,7 @@ test("bridge preserves tool failure messages and classifies invalid requests as
getDaemonTcpHost: null,
serviceProxyPublicBaseUrl: null,
resolveScriptHealth: null,
getGlobalServicePorts: async () => undefined,
logger,
emit: () => {},
publishStatusUpdate: () => {},
@@ -0,0 +1,249 @@
// Frozen pre-index page selection: differential oracle for retained projection tests.
import type { AgentTimelineRow } from "../agent-timeline-store-types.js";
import {
TimelineProjection,
projectTimelineRows,
type TimelineProjectionEntry,
type ProjectedTimelineRow,
type TimelineLimitDirection,
type ProjectedTimelinePageSelection,
} from "../timeline-projection.js";
function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): TimelineProjectionEntry[] {
return rows.map((row) => {
if ("seqStart" in row) return { ...(row as ProjectedTimelineRow) };
return {
item: row.item,
timestamp: row.timestamp,
...(row.turnId ? { turnId: row.turnId } : {}),
...(row.providerMessageId ? { providerMessageId: row.providerMessageId } : {}),
seqStart: row.seq,
seqEnd: row.seq,
sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }],
collapsed: [],
};
});
}
function getTimelineBounds(
rows: readonly AgentTimelineRow[],
): { minSeq: number; maxSeq: number } | null {
const first = rows[0];
const last = rows[rows.length - 1];
if (!first || !last) {
return null;
}
const entries = makeCanonicalEntries(rows);
return {
minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity),
maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0),
};
}
function firstSourceSeqInRange(
entry: TimelineProjectionEntry,
startSeq: number,
endSeq: number,
): number | null {
for (const range of entry.sourceSeqRanges) {
const firstSeq = Math.max(range.startSeq, startSeq);
if (firstSeq <= Math.min(range.endSeq, endSeq)) return firstSeq;
}
return null;
}
interface ProjectedEntryCandidate {
entry: TimelineProjectionEntry;
index: number;
firstSourceSeq: number;
}
function selectProjectedEntriesAfter(input: {
entries: readonly TimelineProjectionEntry[];
startSeq: number;
maxSeq: number;
limit: number;
}): { entries: TimelineProjectionEntry[]; endSeq: number | null } {
const eligible = input.entries
.map((entry, index) => ({
entry,
index,
firstSourceSeq: firstSourceSeqInRange(entry, input.startSeq, input.maxSeq),
}))
.filter((candidate): candidate is ProjectedEntryCandidate => candidate.firstSourceSeq !== null)
.sort((left, right) => left.firstSourceSeq - right.firstSourceSeq || left.index - right.index);
const selected = input.limit === 0 ? eligible : eligible.slice(0, input.limit);
const selectedEntries = selected
.sort((left, right) => left.index - right.index)
.map((candidate) => candidate.entry);
if (selectedEntries.length === 0) return { entries: [], endSeq: null };
const selectedRanges = selectedEntries
.flatMap((entry) => entry.sourceSeqRanges)
.sort((left, right) => left.startSeq - right.startSeq || left.endSeq - right.endSeq);
// Wide projected entries can include future, discontiguous source ranges. The
// page cursor advances only through source rows covered without a gap.
let endSeq = input.startSeq - 1;
for (const range of selectedRanges) {
if (range.endSeq <= endSeq) continue;
if (range.startSeq > endSeq + 1) break;
endSeq = Math.min(range.endSeq, input.maxSeq);
}
return {
entries: selectedEntries,
endSeq: endSeq >= input.startSeq ? endSeq : null,
};
}
function selectProjectedEntriesBefore(input: {
entries: readonly TimelineProjectionEntry[];
endSeq: number;
limit: number;
}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } {
// Older history follows projected display order. Lifecycle updates can move an
// entry's seqEnd without moving its display anchor, so seqStart keeps the full
// projected item on exactly one backward page.
const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq);
const selected =
input.limit === 0 || input.limit >= eligible.length
? eligible
: eligible.slice(eligible.length - input.limit);
return {
entries: selected,
startSeq: selected[0]?.seqStart ?? null,
hasOlder: selected.length < eligible.length,
};
}
function selectProjectedEntriesTail(
projectedAll: TimelineProjectionEntry[],
limit: number,
bounds: { minSeq: number; maxSeq: number },
): ProjectedTimelinePageSelection {
let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit);
// Include earlier display anchors with updates in the selected source window,
// so a tail certifies contiguous coverage even with interleaved tool updates.
for (let i = start - 1; i >= 0; i--) {
if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i;
}
const selected = projectedAll.slice(start);
return {
entries: selected,
startSeq: selected[0]?.seqStart ?? null,
endSeq: bounds.maxSeq,
hasOlder: start > 0,
hasNewer: false,
};
}
export function selectReferenceProjectedTimelinePage(input: {
rows: readonly AgentTimelineRow[];
bounds?: { minSeq: number; maxSeq: number };
direction: TimelineLimitDirection;
cursorSeq?: number;
limit?: number;
}): ProjectedTimelinePageSelection {
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
const bounds = input.bounds ?? getTimelineBounds(input.rows);
const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" });
if (!bounds) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
};
}
if (projectedAll.length === 0) {
if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: cursorSeq >= bounds.minSeq,
hasNewer: cursorSeq < bounds.maxSeq,
};
}
if (input.direction === "before") {
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: cursorSeq > bounds.minSeq,
hasNewer: cursorSeq <= bounds.maxSeq,
};
}
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
};
}
if (input.direction === "tail") {
return selectProjectedEntriesTail(projectedAll, limit, bounds);
}
if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
const startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
const selected = selectProjectedEntriesAfter({
entries: projectedAll,
startSeq,
maxSeq: bounds.maxSeq,
limit,
});
return {
entries: selected.entries,
startSeq: selected.endSeq === null ? null : startSeq,
endSeq: selected.endSeq,
hasOlder: startSeq > bounds.minSeq,
hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq,
};
}
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
if (endSeq < bounds.minSeq) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: endSeq < bounds.maxSeq,
};
}
const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit });
return {
entries: selected.entries,
startSeq: selected.startSeq,
endSeq,
hasOlder: selected.hasOlder,
hasNewer: endSeq < bounds.maxSeq,
};
}
// Existing selection fixtures exercise the retained owner through its public API.
export function selectProjectedTimelinePage(
input: Parameters<typeof selectReferenceProjectedTimelinePage>[0],
) {
const projection = new TimelineProjection(input.rows);
const bounds = input.bounds ?? getTimelineBounds(input.rows) ?? { minSeq: 0, maxSeq: 0 };
const page = projection.selectPage({ ...input, bounds });
return {
...page,
entries: page.entries.map((row) => {
const { seq: _seq, ...entry } = row as ProjectedTimelineRow;
return entry;
}),
};
}
@@ -1,9 +1,9 @@
import { selectProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
import { describe, expect, test } from "vitest";
import type { AgentTimelineRow } from "./agent-manager.js";
import {
projectTimelineRows,
selectProjectedTimelinePage,
selectTimelineWindowByProjectedLimit,
} from "./timeline-projection.js";
@@ -295,12 +295,43 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
/** A retained item. `seq` is its latest source event, not its display anchor. */
export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry;
interface ProjectedTimelinePageOptions {
bounds: { minSeq: number; maxSeq: number };
direction: TimelineLimitDirection;
cursorSeq?: number;
limit?: number;
}
/** Owns projected state; source events are consumed, never retained. */
export class TimelineProjection {
private readonly rows: ProjectedTimelineRow[] = [];
private readonly identities = new Map<string, number>();
private lastAssistantRowIndex: number | null = null;
// Disjoint source-coverage spans in display order. A tail starting inside a
// span must include its first anchor to cover interleaved lifecycle updates.
private readonly spans: { start: number; endSeq: number }[] = [];
private readonly sourceRanges: { startSeq: number; endSeq: number; rowIndex: number }[] = [];
constructor(rows: readonly AgentTimelineRow[] = []) {
for (const row of rows) this.appendProjected(row);
for (const [rowIndex, row] of this.rows.entries()) {
for (const range of row.sourceSeqRanges) this.sourceRanges.push({ ...range, rowIndex });
}
this.sourceRanges.sort((a, b) => a.startSeq - b.startSeq);
}
append(row: AgentTimelineRow): void {
const rowIndex = this.appendProjected(row);
const last = this.sourceRanges.at(-1);
if (last && last.rowIndex === rowIndex && last.endSeq + 1 === row.seq) {
last.endSeq = row.seq;
} else {
this.sourceRanges.push({ startSeq: row.seq, endSeq: row.seq, rowIndex });
}
}
private appendProjected(row: AgentTimelineRow): number {
const entry = makeCanonicalEntries([row])[0];
const identity = timelineItemIdentity(row.item);
const index = identity === null ? undefined : this.identities.get(identity);
@@ -308,7 +339,8 @@ export class TimelineProjection {
const merged = existing ? mergeIdentityEntries(existing, entry) : null;
if (merged && index !== undefined) {
this.rows[index] = { ...merged, seq: merged.seqEnd };
return;
this.extendSpan(index);
return index;
}
const previous = this.rows.at(-1);
const adjacent = previous
@@ -317,16 +349,159 @@ export class TimelineProjection {
if (previous && adjacent.length === 1) {
const mergedText = adjacent[0];
this.rows[this.rows.length - 1] = { ...mergedText, seq: mergedText.seqEnd };
return;
this.extendSpan(this.rows.length - 1);
return this.rows.length - 1;
}
if (identity !== null) this.identities.set(identity, this.rows.length);
if (entry.item.type === "assistant_message") this.lastAssistantRowIndex = this.rows.length;
this.rows.push({ ...entry, seq: entry.seqEnd });
const last = this.spans.at(-1);
if (last && last.endSeq >= entry.seqStart) {
last.endSeq = Math.max(last.endSeq, entry.seqEnd);
} else {
this.spans.push({ start: this.rows.length - 1, endSeq: entry.seqEnd });
}
return this.rows.length - 1;
}
private spanIndex(rowIndex: number): number {
let low = 0;
let high = this.spans.length;
while (low + 1 < high) {
const middle = (low + high) >>> 1;
if (this.spans[middle].start <= rowIndex) low = middle;
else high = middle;
}
return low;
}
private extendSpan(rowIndex: number): void {
const index = this.spanIndex(rowIndex);
const span = this.spans[index];
span.endSeq = Math.max(span.endSeq, this.rows[rowIndex].seqEnd);
let end = index + 1;
while (end < this.spans.length && this.rows[this.spans[end].start].seqStart <= span.endSeq) {
span.endSeq = Math.max(span.endSeq, this.spans[end].endSeq);
end++;
}
this.spans.splice(index + 1, end - index - 1);
}
getRows(): ProjectedTimelineRow[] {
return this.rows.map((row) => ({ ...row }));
}
selectPage(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
if (this.rows.length === 0) {
const cursor =
input.cursorSeq ??
(input.direction === "before" ? input.bounds.maxSeq + 1 : input.bounds.minSeq - 1);
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder:
input.direction === "after"
? cursor >= input.bounds.minSeq
: input.direction === "before" && cursor > input.bounds.minSeq,
hasNewer:
input.direction === "after"
? cursor < input.bounds.maxSeq
: input.direction === "before" && cursor <= input.bounds.maxSeq,
};
}
switch (input.direction) {
case "tail":
return this.selectTail(input);
case "after":
return this.selectAfter(input);
case "before":
return this.selectBefore(input);
}
}
private selectTail(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
const limit = Math.max(0, Math.floor(input.limit ?? 0));
const requestedStart = limit === 0 ? 0 : Math.max(0, this.rows.length - limit);
const start = this.spans[this.spanIndex(requestedStart)].start;
return {
entries: this.rows.slice(start),
startSeq: this.rows[start].seqStart,
endSeq: input.bounds.maxSeq,
hasOlder: start > 0,
hasNewer: false,
};
}
private selectAfter(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
const startSeq = Math.max(
input.bounds.minSeq,
(input.cursorSeq ?? input.bounds.minSeq - 1) + 1,
);
let low = 0;
let high = this.sourceRanges.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (this.sourceRanges[middle].endSeq < startSeq) low = middle + 1;
else high = middle;
}
const indexes = new Set<number>();
const limit = Math.max(0, Math.floor(input.limit ?? 0));
for (let i = low; i < this.sourceRanges.length; i++) {
const range = this.sourceRanges[i];
if (range.startSeq > input.bounds.maxSeq) break;
indexes.add(range.rowIndex);
if (limit > 0 && indexes.size === limit) break;
}
const entries = [...indexes].sort((a, b) => a - b).map((index) => this.rows[index]);
const selected = selectProjectedEntriesAfter({
entries,
startSeq,
maxSeq: input.bounds.maxSeq,
limit,
});
return {
entries: selected.entries,
startSeq: selected.endSeq === null ? null : startSeq,
endSeq: selected.endSeq,
hasOlder: startSeq > input.bounds.minSeq,
hasNewer: selected.endSeq !== null && selected.endSeq < input.bounds.maxSeq,
};
}
private selectBefore(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
const endSeq = Math.min(input.bounds.maxSeq, (input.cursorSeq ?? input.bounds.maxSeq + 1) - 1);
if (endSeq >= input.bounds.minSeq) {
let low = 0;
let high = this.rows.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (this.rows[middle].seqStart <= endSeq) low = middle + 1;
else high = middle;
}
const limit = Math.max(0, Math.floor(input.limit ?? 0));
const start = limit === 0 ? 0 : Math.max(0, low - limit);
return {
entries: this.rows.slice(start, low),
startSeq: this.rows[start]?.seqStart ?? null,
endSeq,
hasOlder: start > 0,
hasNewer: endSeq < input.bounds.maxSeq,
};
}
return { entries: [], startSeq: null, endSeq: null, hasOlder: false, hasNewer: true };
}
getLastItem(seq: number): AgentTimelineItem | null {
const last = this.sourceRanges.at(-1);
return last?.endSeq === seq ? this.rows[last.rowIndex].item : null;
}
getLastAssistantMessage(): string | null {
const row = this.lastAssistantRowIndex === null ? null : this.rows[this.lastAssistantRowIndex];
return row?.item.type === "assistant_message" ? row.item.text : null;
}
enrichSubmittedUserMessage(
clientMessageId: string,
providerMessageId: string,
@@ -444,21 +619,6 @@ export function selectTimelineWindowByProjectedLimit(input: {
};
}
function getTimelineBounds(
rows: readonly AgentTimelineRow[],
): { minSeq: number; maxSeq: number } | null {
const first = rows[0];
const last = rows[rows.length - 1];
if (!first || !last) {
return null;
}
const entries = makeCanonicalEntries(rows);
return {
minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity),
maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0),
};
}
function firstSourceSeqInRange(
entry: TimelineProjectionEntry,
startSeq: number,
@@ -515,142 +675,6 @@ function selectProjectedEntriesAfter(input: {
};
}
function selectProjectedEntriesBefore(input: {
entries: readonly TimelineProjectionEntry[];
endSeq: number;
limit: number;
}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } {
// Older history follows projected display order. Lifecycle updates can move an
// entry's seqEnd without moving its display anchor, so seqStart keeps the full
// projected item on exactly one backward page.
const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq);
const selected =
input.limit === 0 || input.limit >= eligible.length
? eligible
: eligible.slice(eligible.length - input.limit);
return {
entries: selected,
startSeq: selected[0]?.seqStart ?? null,
hasOlder: selected.length < eligible.length,
};
}
function selectProjectedEntriesTail(
projectedAll: TimelineProjectionEntry[],
limit: number,
bounds: { minSeq: number; maxSeq: number },
): ProjectedTimelinePageSelection {
let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit);
// Include earlier display anchors with updates in the selected source window,
// so a tail certifies contiguous coverage even with interleaved tool updates.
for (let i = start - 1; i >= 0; i--) {
if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i;
}
const selected = projectedAll.slice(start);
return {
entries: selected,
startSeq: selected[0]?.seqStart ?? null,
endSeq: bounds.maxSeq,
hasOlder: start > 0,
hasNewer: false,
};
}
export function selectProjectedTimelinePage(input: {
rows: readonly AgentTimelineRow[];
bounds?: { minSeq: number; maxSeq: number };
direction: TimelineLimitDirection;
cursorSeq?: number;
limit?: number;
}): ProjectedTimelinePageSelection {
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
const bounds = input.bounds ?? getTimelineBounds(input.rows);
const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" });
if (!bounds) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
};
}
if (projectedAll.length === 0) {
if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: cursorSeq >= bounds.minSeq,
hasNewer: cursorSeq < bounds.maxSeq,
};
}
if (input.direction === "before") {
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: cursorSeq > bounds.minSeq,
hasNewer: cursorSeq <= bounds.maxSeq,
};
}
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
};
}
if (input.direction === "tail") {
return selectProjectedEntriesTail(projectedAll, limit, bounds);
}
if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
const startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
const selected = selectProjectedEntriesAfter({
entries: projectedAll,
startSeq,
maxSeq: bounds.maxSeq,
limit,
});
return {
entries: selected.entries,
startSeq: selected.endSeq === null ? null : startSeq,
endSeq: selected.endSeq,
hasOlder: startSeq > bounds.minSeq,
hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq,
};
}
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
if (endSeq < bounds.minSeq) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: endSeq < bounds.maxSeq,
};
}
const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit });
return {
entries: selected.entries,
startSeq: selected.startSeq,
endSeq,
hasOlder: selected.hasOlder,
hasNewer: endSeq < bounds.maxSeq,
};
}
/**
* Apply a projected-count limit to a flat AgentTimelineItem[] without seq metadata.
* Used by callers that only have items in hand (e.g. MCP tools reading
+6 -6
View File
@@ -141,11 +141,11 @@ function liveCleanupHost() {
test("bootstrap's host toggle prevents collection live, persists, and leaves other hosts enabled", async () => {
const host = liveCleanupHost();
const other = liveCleanupHost();
host.store.patch({ idleRuntimeCleanup: false });
await host.store.patch({ idleRuntimeCleanup: false });
expect(loadPersistedConfig(host.home).agents?.idleRuntimeCleanup).toBe(false);
expect((await host.sweep()).collected).toEqual([]);
expect((await other.sweep()).collected).toHaveLength(1);
host.store.patch({ idleRuntimeCleanup: true });
await host.store.patch({ idleRuntimeCleanup: true });
host.protect(true);
expect((await host.sweep()).collected).toEqual([]);
host.protect(false);
@@ -160,7 +160,7 @@ test("bootstrap's config-file reload binding disables and enables eligible colle
path.join(host.home, "config.json"),
JSON.stringify({ version: 1, agents: { idleRuntimeCleanup: enabled } }),
);
expect(host.store.reload()).toEqual({
expect(await host.store.reload()).toEqual({
appliedPaths: ["agents.idleRuntimeCleanup"],
restartRequiredPaths: [],
overrideControlledPaths: [],
@@ -173,12 +173,12 @@ test.each([false, true])(
"failed config apply restores prior idle cleanup behavior (previous: %s)",
async (previous) => {
const host = liveCleanupHost();
host.store.patch({ idleRuntimeCleanup: previous });
await host.store.patch({ idleRuntimeCleanup: previous });
const persistedBefore = loadPersistedConfig(host.home);
const refuse = host.store.onApply(() => {
throw new Error("Another live owner refused");
});
expect(() => host.store.patch({ idleRuntimeCleanup: !previous })).toThrow(
await expect(host.store.patch({ idleRuntimeCleanup: !previous })).rejects.toThrow(
"Another live owner refused",
);
expect(host.store.get().idleRuntimeCleanup).toBe(previous);
@@ -191,6 +191,6 @@ test.each([false, true])(
test("disposing bootstrap's binding stops configuration delivery", async () => {
const host = liveCleanupHost();
host.unsubscribe();
host.store.patch({ idleRuntimeCleanup: false });
await host.store.patch({ idleRuntimeCleanup: false });
expect((await host.sweep()).collected).toHaveLength(1);
});
+11 -7
View File
@@ -161,7 +161,11 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js";
import { createOrchestrationSkills } from "./orchestration-skills/index.js";
import { resolveConfigFromPersisted, type CliConfigOverrides } from "./config.js";
import {
resolveReloadableConfig,
type ResolvedDaemonSettings,
type CliConfigOverrides,
} from "./config.js";
import { resolvePaseoToolPolicy } from "./agent/paseo-tool-policy.js";
import { BrowserToolsBroker } from "./browser-tools/broker.js";
import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js";
@@ -196,7 +200,7 @@ import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
import { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
import { type PersistedConfig } from "./persisted-config.js";
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
import { ScriptHealthMonitor } from "./script-health-monitor.js";
@@ -551,7 +555,7 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri
return config.trustedProxies ?? ["loopback"];
}
function createInitialRelayConfig(config: PaseoDaemonConfig): MutableDaemonConfig["relay"] {
function createInitialRelayConfig(config: ResolvedDaemonSettings): MutableDaemonConfig["relay"] {
return {
enabled: config.relayEnabled ?? true,
requireProtectedSecurity: config.relayRequireProtectedSecurity ?? false,
@@ -562,7 +566,7 @@ function resolveReloadEnv(config: PaseoDaemonConfig): NodeJS.ProcessEnv {
return config.configReload?.env ?? process.env;
}
function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig {
function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): MutableDaemonConfig {
const providers = config.providerOverrides ?? {};
const initialConfig: MutableDaemonConfig = {
@@ -638,14 +642,14 @@ export async function createPaseoDaemon(
}),
reloadSource: {
resolve: (persisted) => {
const reloaded = resolveConfigFromPersisted(config.paseoHome, persisted, {
const reloaded = resolveReloadableConfig(persisted, {
env: resolveReloadEnv(config),
cli: config.configReload?.cli,
relayEnabledFallback: config.configReload?.relayEnabledFallback,
});
return {
mutable: createInitialMutableDaemonConfig(reloaded),
overrideControlledPaths: reloaded.configReload?.overrideControlledPaths ?? [],
overrideControlledPaths: reloaded.overrideControlledPaths,
};
},
},
@@ -1541,7 +1545,7 @@ export async function createPaseoDaemon(
spawnWorkspaceScript,
assertAutomationAllowed: (workspaceId) =>
assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, workspaceId),
globalServicePorts: loadPersistedConfig(config.paseoHome).worktrees?.servicePorts,
getGlobalServicePorts: () => daemonConfigStore.readServicePorts(),
}),
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
@@ -132,3 +132,22 @@ test.each(["base", "uncommitted"] as const)(
expect(await read("next", "next-after")).toEqual({ diff: "next-after" });
},
);
test("superseding a diff cancels its highlighting while identical readers share work", async () => {
const cache = new CheckoutDiffCache(() => 0);
const release = Promise.withResolvers<{ diff: string }>();
let activeSignal: AbortSignal | undefined;
const load = (signal: AbortSignal) => {
activeSignal = signal;
return release.promise;
};
const first = cache.read({ cwd: "repo", compare: { mode: "base" }, load });
await Promise.resolve();
expect(activeSignal).toBeDefined();
expect(activeSignal!.aborted).toBe(false);
expect(cache.read({ cwd: "repo", compare: { mode: "base" }, load })).toBe(first);
cache.invalidate("repo", "base");
expect(activeSignal!.aborted).toBe(true);
release.resolve({ diff: "old snapshot" });
await first;
});
@@ -4,6 +4,7 @@ import type { CheckoutDiffCompare, CheckoutDiffResult } from "../utils/checkout-
interface ActiveRead {
promise: Promise<CheckoutDiffResult>;
invalidated: boolean;
controller: AbortController;
}
interface CachedDiff {
@@ -17,7 +18,7 @@ interface CheckoutDiffReadInput {
compare: CheckoutDiffCompare;
force?: boolean;
reason?: string;
load: () => Promise<CheckoutDiffResult>;
load: (signal: AbortSignal) => Promise<CheckoutDiffResult>;
}
/** Completed payloads are bounded; active work survives invalidation and eviction. */
@@ -51,6 +52,7 @@ export class CheckoutDiffCache {
if (pending) {
if (force) {
pending.invalidated = true;
pending.controller.abort();
this.values.delete(key);
}
if (!pending.invalidated) return pending.promise;
@@ -66,8 +68,9 @@ export class CheckoutDiffCache {
// Defer load until ownership is installed, including synchronous invalidations.
const read: ActiveRead = {
invalidated: false,
controller: new AbortController(),
promise: Promise.resolve()
.then(load)
.then(() => load(read.controller.signal))
.then((value) => {
// Never repopulate the cache with a snapshot invalidated during its build.
if (!read.invalidated)
@@ -86,7 +89,10 @@ export class CheckoutDiffCache {
if (cachedCwd !== cwd || (cachedMode !== mode && cachedMode !== "all")) continue;
this.values.delete(key);
const pending = this.active.get(key);
if (pending) pending.invalidated = true;
if (pending) {
pending.invalidated = true;
pending.controller.abort();
}
}
}
}
+30 -1
View File
@@ -4,7 +4,12 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, test } from "vitest";
import { loadConfig, resolveBundledWebUiDistDir, resolveConfigFromPersisted } from "./config.js";
import {
loadConfig,
resolveBundledWebUiDistDir,
resolveConfigFromPersisted,
resolveReloadableConfig,
} from "./config.js";
import { loadPersistedConfig } from "./persisted-config.js";
const roots: string[] = [];
@@ -14,6 +19,30 @@ describe("server config", () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("resolves reloadable settings without resolving the startup password", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-auth-reload-"));
roots.push(paseoHome);
const env = { PASEO_PASSWORD: "synthetic-audit-password" };
const persisted = loadPersistedConfig(paseoHome);
const startup = resolveConfigFromPersisted(paseoHome, persisted, { env });
const started = performance.now();
const timer = new Promise<number>((resolve) =>
setTimeout(() => resolve(performance.now() - started), 0),
);
const reloaded = resolveReloadableConfig(
{
...persisted,
daemon: { ...persisted.daemon, browserTools: { enabled: true } },
},
{ env },
);
const gap = await timer;
expect(reloaded).not.toHaveProperty("auth");
expect(reloaded.browserToolsEnabled).toBe(true);
expect(startup.auth?.password).toBeTypeOf("string");
expect(gap).toBeLessThan(50);
});
test("records when the daemon is managed by Paseo Desktop", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-desktop-managed-"));
roots.push(paseoHome);
+71 -60
View File
@@ -568,31 +568,43 @@ interface ResolveConfigFromPersistedOptions {
relayEnabledFallback?: boolean;
}
// Startup values, including authentication, never enter runtime settings resolution.
export type ResolvedDaemonSettings = Pick<
PaseoDaemonConfig,
| "corsAllowedOrigins"
| "hostnames"
| "trustedProxies"
| "mcpEnabled"
| "mcpInjectIntoAgents"
| "browserToolsEnabled"
| "git"
| "autoArchiveAfterMerge"
| "enableTerminalAgentHooks"
| "appendSystemPrompt"
| "terminalProfiles"
| "agentProfiles"
| "skillSelection"
| "pluginsEnabled"
| "plugins"
| "relayEnabled"
| "relayRequireProtectedSecurity"
| "appBaseUrl"
| "idleRuntimeCleanup"
| "providerCatalogRefreshTimeoutMs"
| "metadataGeneration"
| "providerOverrides"
>;
export function resolveConfigFromPersisted(
paseoHome: string,
persisted: PersistedConfig,
options?: ResolveConfigFromPersistedOptions,
): PaseoDaemonConfig {
const resolvedOptions = options ?? {};
const env = configurationEnvironment(resolvedOptions.env ?? process.env);
const cli = resolvedOptions.cli;
const env = configurationEnvironment(options?.env ?? process.env);
const cli = options?.cli;
const relayEnabledFallback =
resolvedOptions.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined;
const listen = resolveListenAddress(env, cli, persisted);
const {
mcpEnabled,
mcpInjectIntoAgents,
browserToolsEnabled,
autoArchiveAfterMerge,
appendSystemPrompt,
terminalProfiles,
agentProfiles,
hostnames,
trustedProxies,
appBaseUrl,
} = resolveStaticLoadConfigSettings(env, cli, persisted);
options?.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined;
const { overrideControlledPaths, ...settings } = resolveReloadableConfig(persisted, options);
const relay = resolveRelayConfig({
env,
persisted,
@@ -600,42 +612,14 @@ export function resolveConfigFromPersisted(
cliRelayUseTls: cli?.relayUseTls,
enabledFallback: relayEnabledFallback,
});
const serviceProxy = resolveServiceProxyConfig(env, persisted);
const webUi = resolveWebUiConfig(paseoHome, env, cli, persisted);
const { openai, speech } = resolveSpeechConfig({
paseoHome,
env,
persisted,
});
const { openai, speech } = resolveSpeechConfig({ paseoHome, env, persisted });
const voiceLlm = resolveVoiceLlmConfig(env, persisted);
const providerOverrides = extractProviderOverrides(
persisted.agents?.providers as Record<string, unknown> | undefined,
);
const overrideControlledPaths = resolveOverrideControlledPaths(env, cli, persisted);
return {
listen,
...settings,
listen: resolveListenAddress(env, cli, persisted),
paseoHome,
desktopManaged: env.PASEO_DESKTOP_MANAGED === "1",
worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
hostnames,
trustedProxies,
mcpEnabled,
mcpInjectIntoAgents,
browserToolsEnabled,
git: resolveGitProcessConfig(env, persisted),
autoArchiveAfterMerge,
enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false,
appendSystemPrompt,
terminalProfiles,
agentProfiles,
skillSelection: persisted.agents?.skills?.selection,
pluginsEnabled: persisted.pluginsEnabled ?? false,
plugins: persisted.plugins,
pluginRegistries: persisted.pluginRegistries,
pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY,
mcpDebug: env.MCP_DEBUG === "1",
@@ -643,27 +627,20 @@ export function resolveConfigFromPersisted(
agentStoragePath: path.join(paseoHome, "agents"),
staticDir: "public",
agentClients: {},
relayEnabled: relay.enabled,
relayEnabledMutable: relay.enabledMutable,
relayRequireProtectedSecurity: relay.requireProtectedSecurity,
relayEndpoint: relay.endpoint,
relayPublicEndpoint: relay.publicEndpoint,
relayUseTls: relay.useTls,
relayPublicUseTls: relay.publicUseTls,
serviceProxy,
webUi,
appBaseUrl,
serviceProxy: resolveServiceProxyConfig(env, persisted),
webUi: resolveWebUiConfig(paseoHome, env, cli, persisted),
auth: resolveAuthConfig(env, persisted),
openai,
speech,
voiceLlmProvider: voiceLlm.provider,
voiceLlmProviderExplicit: voiceLlm.providerExplicit,
voiceLlmModel: voiceLlm.model,
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true,
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
metadataGeneration: persisted.agents?.metadataGeneration,
providerOverrides,
agentProviderSettings: extractAgentProviderSettings(settings.providerOverrides),
log: resolveLogConfigFromEnv(env, persisted),
configReload: {
env: { ...env },
@@ -675,6 +652,40 @@ export function resolveConfigFromPersisted(
};
}
export function resolveReloadableConfig(
persisted: PersistedConfig,
options?: ResolveConfigFromPersistedOptions,
): ResolvedDaemonSettings & { overrideControlledPaths: string[] } {
const env = configurationEnvironment(options?.env ?? process.env);
const cli = options?.cli;
const relay = resolveRelayConfig({
env,
persisted,
cliRelayEnabled: cli?.relayEnabled,
cliRelayUseTls: cli?.relayUseTls,
enabledFallback:
options?.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined,
});
return {
...resolveStaticLoadConfigSettings(env, cli, persisted),
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
git: resolveGitProcessConfig(env, persisted),
enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false,
skillSelection: persisted.agents?.skills?.selection,
pluginsEnabled: persisted.pluginsEnabled ?? false,
plugins: persisted.plugins,
relayEnabled: relay.enabled,
relayRequireProtectedSecurity: relay.requireProtectedSecurity,
idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true,
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
metadataGeneration: persisted.agents?.metadataGeneration,
providerOverrides: extractProviderOverrides(
persisted.agents?.providers as Record<string, unknown> | undefined,
),
overrideControlledPaths: resolveOverrideControlledPaths(env, cli, persisted),
};
}
export function loadConfig(
paseoHome: string,
options?: Omit<ResolveConfigFromPersistedOptions, "relayEnabledFallback">,
@@ -101,7 +101,7 @@ describe("DaemonConfigStore", () => {
}
});
test("patch persists relay state and emits its field change", () => {
test("patch persists relay state and emits its field change", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
@@ -117,13 +117,13 @@ describe("DaemonConfigStore", () => {
const changes: unknown[] = [];
store.onFieldChange("relay.enabled", (value) => changes.push(value));
store.patch({ relay: { enabled: true } });
await store.patch({ relay: { enabled: true } });
expect(changes).toEqual([true]);
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true);
});
test("patch persists the relay protected security requirement independently of relay enablement", () => {
test("patch persists the relay protected security requirement independently of relay enablement", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(
@@ -145,14 +145,14 @@ describe("DaemonConfigStore", () => {
const changes: unknown[] = [];
store.onFieldChange("relay.requireProtectedSecurity", (value) => changes.push(value));
store.patch({ relay: { requireProtectedSecurity: true } });
await store.patch({ relay: { requireProtectedSecurity: true } });
expect(changes).toEqual([true]);
expect(store.get().relay).toEqual({ enabled: true, requireProtectedSecurity: true });
expect(loadPersistedConfig(paseoHome).daemon?.relay?.requireProtectedSecurity).toBe(true);
});
test("patch round-trips agent profiles through the strictly-parsed persisted config", () => {
test("patch round-trips agent profiles through the strictly-parsed persisted config", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
@@ -166,7 +166,7 @@ describe("DaemonConfigStore", () => {
appendSystemPrompt: "",
});
store.patch({
await store.patch({
agentProfiles: [
{
id: "profile_ui",
@@ -198,7 +198,7 @@ describe("DaemonConfigStore", () => {
expect(store.get().agentProfiles).toHaveLength(1);
});
test("patch replaces the whole agent profile list rather than merging entries", () => {
test("patch replaces the whole agent profile list rather than merging entries", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
@@ -216,13 +216,13 @@ describe("DaemonConfigStore", () => {
],
});
store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] });
await store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] });
expect(store.get().agentProfiles).toEqual([{ id: "a", name: "Keep", provider: "claude" }]);
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toHaveLength(1);
});
test("rolls back config when a field transition fails", () => {
test("rolls back config when a field transition fails", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
@@ -241,14 +241,14 @@ describe("DaemonConfigStore", () => {
}
});
expect(() => store.patch({ relay: { enabled: true } })).toThrow(
await expect(store.patch({ relay: { enabled: true } })).rejects.toThrow(
"Relay transport failed to start",
);
expect(store.get().relay?.enabled).toBe(false);
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
});
test("rolls back live owners when a later transactional owner fails", () => {
test("rolls back live owners when a later transactional owner fails", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
@@ -272,7 +272,7 @@ describe("DaemonConfigStore", () => {
throw new Error("Provider refresh failed");
});
expect(() => store.patch({ browserTools: { enabled: true } })).toThrow(
await expect(store.patch({ browserTools: { enabled: true } })).rejects.toThrow(
"Provider refresh failed",
);
expect(browserToolsEnabled).toBe(false);
@@ -280,7 +280,7 @@ describe("DaemonConfigStore", () => {
expect(loadPersistedConfig(paseoHome).daemon?.browserTools?.enabled).toBeUndefined();
});
test("rejects relay patches when a launch override owns the setting", () => {
test("rejects relay patches when a launch override owns the setting", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(
@@ -299,12 +299,12 @@ describe("DaemonConfigStore", () => {
{ relayEnabledMutable: false },
);
expect(() => store.patch({ relay: { enabled: true } })).toThrow(
await expect(store.patch({ relay: { enabled: true } })).rejects.toThrow(
"Relay is controlled by a daemon launch override",
);
});
test("unrelated patches do not persist a one-launch relay override", () => {
test("unrelated patches do not persist a one-launch relay override", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const persisted = loadPersistedConfig(paseoHome);
@@ -331,12 +331,12 @@ describe("DaemonConfigStore", () => {
{ relayEnabledMutable: false },
);
store.patch({ browserTools: { enabled: true } });
await store.patch({ browserTools: { enabled: true } });
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
});
test("unrelated patches persist only requested file intent", () => {
test("unrelated patches persist only requested file intent", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const before = loadPersistedConfig(paseoHome);
@@ -362,7 +362,7 @@ describe("DaemonConfigStore", () => {
{ relayEnabledMutable: false },
);
store.patch({
await store.patch({
appendSystemPrompt: "Only this field",
// Reload-only runtime state is accepted as unknown wire data for forward
// compatibility but is not part of the patch capability.
@@ -376,7 +376,7 @@ describe("DaemonConfigStore", () => {
});
});
test("patch persists provider enabled flags into config.json", () => {
test("patch persists provider enabled flags into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -417,7 +417,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({
await store.patch({
providers: {
gemini: { enabled: false },
},
@@ -432,7 +432,7 @@ describe("DaemonConfigStore", () => {
});
});
test("patch persists provider Paseo-tool policy without changing availability", () => {
test("patch persists provider Paseo-tool policy without changing availability", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
writeFileSync(
@@ -449,14 +449,14 @@ describe("DaemonConfigStore", () => {
appendSystemPrompt: "",
});
store.patch({
await store.patch({
providers: {
claude: {
paseoTools: { enabled: true, disabledTools: ["list_agents"] },
},
},
});
store.patch({
await store.patch({
providers: {
claude: {
paseoTools: { disabledTools: ["create_agent"] },
@@ -474,7 +474,7 @@ describe("DaemonConfigStore", () => {
});
});
test("patch removes provider entries from config.json", () => {
test("patch removes provider entries from config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -519,7 +519,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
const next = store.patch({ removeProviders: ["gemini"] });
const next = await store.patch({ removeProviders: ["gemini"] });
expect(next.providers.gemini).toBeUndefined();
expect(next.providers.claude).toEqual({ enabled: false });
@@ -528,7 +528,7 @@ describe("DaemonConfigStore", () => {
expect(persisted.agents?.providers?.claude).toEqual({ enabled: false });
});
test("patch removes the providers object when the last provider is deleted", () => {
test("patch removes the providers object when the last provider is deleted", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -567,13 +567,13 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({ removeProviders: ["gemini"] });
await store.patch({ removeProviders: ["gemini"] });
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.agents?.providers).toBeUndefined();
});
test("patch removes deleted providers from metadata generation", () => {
test("patch removes deleted providers from metadata generation", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -629,7 +629,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
const next = store.patch({ removeProviders: ["gemini"] });
const next = await store.patch({ removeProviders: ["gemini"] });
expect(next.metadataGeneration.providers).toEqual([{ provider: "claude", model: "haiku" }]);
const persisted = loadPersistedConfig(paseoHome);
@@ -638,7 +638,7 @@ describe("DaemonConfigStore", () => {
});
});
test("patch persists provider removal when in-memory config is already clean", () => {
test("patch persists provider removal when in-memory config is already clean", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -680,7 +680,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
const next = store.patch({ removeProviders: ["gemini"] });
const next = await store.patch({ removeProviders: ["gemini"] });
expect(next.providers.gemini).toBeUndefined();
const persisted = loadPersistedConfig(paseoHome);
@@ -688,7 +688,7 @@ describe("DaemonConfigStore", () => {
expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] });
});
test("patch persists append system prompt into config.json", () => {
test("patch persists append system prompt into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -706,7 +706,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({
await store.patch({
appendSystemPrompt: "Prefer terse replies.",
});
@@ -714,7 +714,7 @@ describe("DaemonConfigStore", () => {
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
});
test("patch persists browser tools opt-in into config.json", () => {
test("patch persists browser tools opt-in into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -731,13 +731,13 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({ browserTools: { enabled: true } });
await store.patch({ browserTools: { enabled: true } });
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.daemon?.browserTools).toEqual({ enabled: true });
});
test("patch persists provider additional models into config.json", () => {
test("patch persists provider additional models into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -755,7 +755,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({
await store.patch({
providers: {
claude: {
additionalModels: [
@@ -779,7 +779,7 @@ describe("DaemonConfigStore", () => {
});
});
test("patch persists daemon append system prompt into config.json", () => {
test("patch persists daemon append system prompt into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -797,7 +797,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({
await store.patch({
appendSystemPrompt: "Prefer terse replies.",
});
@@ -805,7 +805,7 @@ describe("DaemonConfigStore", () => {
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
});
test("patch persists enable terminal agent hooks into config.json", () => {
test("patch persists enable terminal agent hooks into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -822,13 +822,13 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({ enableTerminalAgentHooks: true });
await store.patch({ enableTerminalAgentHooks: true });
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.daemon?.enableTerminalAgentHooks).toBe(true);
});
test("patch persists metadata generation providers into config.json", () => {
test("patch persists metadata generation providers into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -846,7 +846,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({
await store.patch({
metadataGeneration: {
providers: [
{ provider: "claude", model: "haiku" },
@@ -864,7 +864,7 @@ describe("DaemonConfigStore", () => {
});
});
test("patch persists clearing metadata generation providers into config.json", () => {
test("patch persists clearing metadata generation providers into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -899,13 +899,13 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({ metadataGeneration: { providers: [] } });
await store.patch({ metadataGeneration: { providers: [] } });
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] });
});
test("patch persists the host idle runtime cleanup opt-out for the next daemon start", () => {
test("patch persists the host idle runtime cleanup opt-out for the next daemon start", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
writeFileSync(
@@ -930,7 +930,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
expect(store.patch({ idleRuntimeCleanup: false }).idleRuntimeCleanup).toBe(false);
expect((await store.patch({ idleRuntimeCleanup: false })).idleRuntimeCleanup).toBe(false);
expect(loadPersistedConfig(paseoHome).agents).toEqual({
metadataGeneration: { providers: [{ provider: "claude" }] },
@@ -938,11 +938,11 @@ describe("DaemonConfigStore", () => {
});
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false);
store.patch({ idleRuntimeCleanup: true });
await store.patch({ idleRuntimeCleanup: true });
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true);
});
test("patch persists custom ACP provider overrides into config.json", () => {
test("patch persists custom ACP provider overrides into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -960,7 +960,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({
await store.patch({
providers: {
"paseo-e2e-acp": {
extends: "acp",
@@ -1028,7 +1028,49 @@ describe("DaemonConfigStore reload", () => {
writeFileSync(path.join(paseoHome, "config.json"), `${JSON.stringify(config, null, 2)}\n`);
}
test("applies mutable edits and reports startup-only edits", () => {
test("applies overlapping plugin mutations to the latest committed sources", async () => {
const { store } = createReloadableStore({
initialPersisted: {
plugins: {
a: { source: "directory", path: "/a", enabled: false },
b: { source: "directory", path: "/b", enabled: true },
},
},
});
const enable = store.patch((current) => ({
plugins: {
...current.plugins,
a: { ...current.plugins.a!, enabled: true },
},
}));
const disable = store.patch((current) => ({
plugins: {
...current.plugins,
b: { ...current.plugins.b!, enabled: false },
},
}));
await Promise.all([enable, disable]);
expect(store.get().plugins).toEqual({
a: { source: "directory", path: "/a", enabled: true },
b: { source: "directory", path: "/b", enabled: false },
});
});
test("serializes overlapping patches before committing their combined state", async () => {
const { paseoHome, store } = createReloadableStore();
const first = store.patch({ appendSystemPrompt: "first" });
const second = store.patch({ browserTools: { enabled: true } });
expect(store.get().appendSystemPrompt).toBe("");
await Promise.all([first, second]);
expect(store.get().appendSystemPrompt).toBe("first");
expect(store.get().browserTools.enabled).toBe(true);
expect(loadPersistedConfig(paseoHome).daemon).toMatchObject({
appendSystemPrompt: "first",
browserTools: { enabled: true },
});
});
test("applies mutable edits and reports startup-only edits", async () => {
const { paseoHome, store, persisted } = createReloadableStore();
writeConfig(paseoHome, {
...persisted,
@@ -1040,7 +1082,7 @@ describe("DaemonConfigStore reload", () => {
},
});
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: [
"daemon.browserTools.enabled",
"daemon.git.maxProcessConcurrency",
@@ -1053,13 +1095,13 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().git).toEqual({ maxProcessesPerSecond: 12, maxProcessConcurrency: 3 });
});
test("reloads idle runtime cleanup in both directions without requiring a restart", () => {
test("reloads idle runtime cleanup in both directions without requiring a restart", async () => {
const { paseoHome, store, persisted } = createReloadableStore();
const changes: unknown[] = [];
store.onFieldChange("idleRuntimeCleanup", (value) => changes.push(value));
for (const enabled of [false, true]) {
writeConfig(paseoHome, { ...persisted, agents: { idleRuntimeCleanup: enabled } });
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: ["agents.idleRuntimeCleanup"],
restartRequiredPaths: [],
overrideControlledPaths: [],
@@ -1067,8 +1109,8 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().idleRuntimeCleanup).toBe(enabled);
}
expect(changes).toEqual([false, true]);
store.patch({ idleRuntimeCleanup: false });
expect(store.reload()).toEqual({
await store.patch({ idleRuntimeCleanup: false });
expect(await store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: [],
@@ -1076,7 +1118,7 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().idleRuntimeCleanup).toBe(false);
});
test("applies the global plugin switch in both directions", () => {
test("applies the global plugin switch in both directions", async () => {
const { paseoHome, store, persisted } = createReloadableStore({
initialPersisted: { version: 1, pluginsEnabled: false },
});
@@ -1084,7 +1126,7 @@ describe("DaemonConfigStore reload", () => {
store.onFieldChange("pluginsEnabled", (value) => changes.push(value));
writeConfig(paseoHome, { ...persisted, pluginsEnabled: true });
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: ["pluginsEnabled"],
restartRequiredPaths: [],
overrideControlledPaths: [],
@@ -1092,7 +1134,7 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().pluginsEnabled).toBe(true);
writeConfig(paseoHome, { ...persisted, pluginsEnabled: false });
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: ["pluginsEnabled"],
restartRequiredPaths: [],
overrideControlledPaths: [],
@@ -1101,7 +1143,7 @@ describe("DaemonConfigStore reload", () => {
expect(changes).toEqual([true, false]);
});
test("classifies every leaf when a parent subtree is added", () => {
test("classifies every leaf when a parent subtree is added", async () => {
const { paseoHome, store } = createReloadableStore({
initialPersisted: { version: 1 },
});
@@ -1116,14 +1158,14 @@ describe("DaemonConfigStore reload", () => {
},
});
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: ["daemon.relay.enabled"],
restartRequiredPaths: ["daemon.relay.endpoint", "daemon.relay.useTls"],
overrideControlledPaths: [],
});
});
test("classifies every leaf when the daemon subtree is removed", () => {
test("classifies every leaf when the daemon subtree is removed", async () => {
const { paseoHome, store } = createReloadableStore({
initialPersisted: {
version: 1,
@@ -1144,7 +1186,7 @@ describe("DaemonConfigStore reload", () => {
});
writeConfig(paseoHome, { version: 1 });
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: [
"daemon.listen",
@@ -1158,7 +1200,7 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().relay?.enabled).toBe(false);
});
test("keeps overridden leaves separate from restart-required siblings", () => {
test("keeps overridden leaves separate from restart-required siblings", async () => {
const { paseoHome, store } = createReloadableStore({
initialPersisted: { version: 1 },
overrideControlledPaths: ["daemon.relay.enabled"],
@@ -1170,25 +1212,25 @@ describe("DaemonConfigStore reload", () => {
},
});
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: ["daemon.relay.endpoint"],
overrideControlledPaths: ["daemon.relay.enabled"],
});
});
test("invalid JSON and invalid schema apply nothing", () => {
test("invalid JSON and invalid schema apply nothing", async () => {
const { paseoHome, store } = createReloadableStore();
writeFileSync(path.join(paseoHome, "config.json"), "{ nope\n");
expect(() => store.reload()).toThrow("Invalid JSON");
await expect(store.reload()).rejects.toThrow("Invalid JSON");
expect(store.get().browserTools.enabled).toBe(false);
writeConfig(paseoHome, { daemon: { browserTools: { enabled: "yes" } } });
expect(() => store.reload()).toThrow("Invalid config");
await expect(store.reload()).rejects.toThrow("Invalid config");
expect(store.get().browserTools.enabled).toBe(false);
});
test("removing providers and optional profiles clears live state", () => {
test("removing providers and optional profiles clears live state", async () => {
const { paseoHome, store, persisted } = createReloadableStore();
writeConfig(paseoHome, {
...persisted,
@@ -1203,10 +1245,10 @@ describe("DaemonConfigStore reload", () => {
},
},
});
store.reload();
await store.reload();
writeConfig(paseoHome, persisted);
const result = store.reload();
const result = await store.reload();
expect(result.appliedPaths).toEqual([
"agents.providers",
@@ -1218,7 +1260,7 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().agentProfiles).toBeUndefined();
});
test("reports a launch-controlled edit without changing live state", () => {
test("reports a launch-controlled edit without changing live state", async () => {
const { paseoHome, store, persisted } = createReloadableStore({
overrideControlledPaths: ["daemon.relay.enabled"],
});
@@ -1228,7 +1270,7 @@ describe("DaemonConfigStore reload", () => {
daemon: { ...persisted.daemon, relay: { enabled: !initialRelay } },
});
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: ["daemon.relay.enabled"],
@@ -1236,7 +1278,7 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().relay?.enabled).toBe(initialRelay);
});
test("an unrelated patch does not mark a manual override-owned edit as applied", () => {
test("an unrelated patch does not mark a manual override-owned edit as applied", async () => {
const { paseoHome, store, persisted } = createReloadableStore({
overrideControlledPaths: ["daemon.relay.enabled"],
});
@@ -1244,16 +1286,16 @@ describe("DaemonConfigStore reload", () => {
...persisted,
daemon: { ...persisted.daemon, relay: { enabled: true } },
});
store.patch({ appendSystemPrompt: "patched elsewhere" });
await store.patch({ appendSystemPrompt: "patched elsewhere" });
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: ["daemon.relay.enabled"],
});
});
test("reports startup-only launch overrides instead of restart warnings", () => {
test("reports startup-only launch overrides instead of restart warnings", async () => {
const { paseoHome, store, persisted } = createReloadableStore({
overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"],
});
@@ -1269,16 +1311,16 @@ describe("DaemonConfigStore reload", () => {
},
});
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"],
});
});
test("a no-op reload returns empty path lists", () => {
test("a no-op reload returns empty path lists", async () => {
const { store } = createReloadableStore();
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: [],
@@ -1317,25 +1359,25 @@ describe("DaemonConfigStore speech settings", () => {
return { paseoHome, store, applied, persisted };
}
test("reads, patches, and clears local STT language without losing it on other saves", () => {
test("reads, patches, and clears local STT language without losing it on other saves", async () => {
const { paseoHome, store } = createSpeechStore({
features: { dictation: { stt: { language: "fr" } } },
});
expect(store.get().features?.dictation.stt).toEqual({ provider: "local", language: "fr" });
store.patch({ features: { voiceMode: { enabled: true } } });
await store.patch({ features: { voiceMode: { enabled: true } } });
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({
provider: "local",
language: "fr",
});
const patched = store.patch({
const patched = await store.patch({
features: { dictation: { stt: { provider: "local", language: "de" } } },
});
expect(patched.features?.dictation.stt).toEqual({ provider: "local", language: "de" });
store.patch({ features: { dictation: { stt: { language: null } } } });
await store.patch({ features: { dictation: { stt: { language: null } } } });
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ provider: "local" });
});
test("reports language env overrides for both local STT sites", () => {
test("reports language env overrides for both local STT sites", async () => {
const { store } = createSpeechStore(
{},
{ PASEO_DICTATION_LANGUAGE: "fr", PASEO_VOICE_LANGUAGE: "de" },
@@ -1346,22 +1388,22 @@ describe("DaemonConfigStore speech settings", () => {
});
});
test("keeps voice mode off until it is turned on", () => {
test("keeps voice mode off until it is turned on", async () => {
const { paseoHome, store } = createSpeechStore();
expect(store.get().features?.dictation.enabled).toBe(true);
expect(store.get().features?.voiceMode.enabled).toBe(false);
const result = store.patch({ features: { voiceMode: { enabled: true } } });
const result = await store.patch({ features: { voiceMode: { enabled: true } } });
expect(result.features?.voiceMode.enabled).toBe(true);
expect(loadPersistedConfig(paseoHome).features?.voiceMode?.enabled).toBe(true);
});
test("stores the OpenAI key without ever returning it", () => {
test("stores the OpenAI key without ever returning it", async () => {
const { paseoHome, store, applied } = createSpeechStore();
const result = store.patch({
const result = await store.patch({
features: { dictation: { stt: { provider: "openai", apiKey: " sk-test " } } },
});
@@ -1373,26 +1415,26 @@ describe("DaemonConfigStore speech settings", () => {
expect(applied).toHaveLength(1);
});
test("applies a replaced key even though the visible settings do not change", () => {
test("applies a replaced key even though the visible settings do not change", async () => {
const { store, applied } = createSpeechStore({
version: 1,
features: { dictation: { stt: { provider: "openai", apiKey: "sk-old" } } },
});
store.patch({ features: { dictation: { stt: { apiKey: "sk-new" } } } });
await store.patch({ features: { dictation: { stt: { apiKey: "sk-new" } } } });
expect(applied.map((next) => next.features?.dictation?.stt)).toEqual([
{ provider: "openai", apiKey: "sk-new" },
]);
});
test("switching provider clears the previous provider's model", () => {
test("switching provider clears the previous provider's model", async () => {
const { paseoHome, store } = createSpeechStore({
version: 1,
features: { dictation: { stt: { provider: "local", model: "parakeet-tdt-0.6b-v3-int8" } } },
});
const result = store.patch({ features: { dictation: { stt: { provider: "openai" } } } });
const result = await store.patch({ features: { dictation: { stt: { provider: "openai" } } } });
expect(result.features?.dictation.stt).toEqual({ provider: "openai", hasApiKey: false });
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({
@@ -1400,62 +1442,62 @@ describe("DaemonConfigStore speech settings", () => {
});
});
test("rejects a local model id outside the catalog without persisting it", () => {
test("rejects a local model id outside the catalog without persisting it", async () => {
const { paseoHome, store, applied } = createSpeechStore();
expect(() =>
await expect(
store.patch({ features: { voiceMode: { tts: { provider: "local", model: "nope" } } } }),
).toThrow("Unknown local voice model: nope");
).rejects.toThrow("Unknown local voice model: nope");
expect(loadPersistedConfig(paseoHome).features?.voiceMode).toBeUndefined();
expect(applied).toEqual([]);
});
test("reload applies hand-edited speech settings without a restart", () => {
test("reload applies hand-edited speech settings without a restart", async () => {
const { paseoHome, store, applied, persisted } = createSpeechStore();
writeFileSync(
path.join(paseoHome, "config.json"),
`${JSON.stringify({ ...persisted, features: { voiceMode: { enabled: false } } }, null, 2)}\n`,
);
const result = store.reload();
const result = await store.reload();
expect(result.appliedPaths).toContain("features.voiceMode");
expect(result.restartRequiredPaths).toEqual([]);
expect(store.get().features?.voiceMode.enabled).toBe(false);
expect(applied).toHaveLength(1);
});
test("merges same-provider fields and replaces the whole variant when provider changes", () => {
test("merges same-provider fields and replaces the whole variant when provider changes", async () => {
const { paseoHome, store } = createSpeechStore();
store.patch({
await store.patch({
features: {
voiceMode: {
stt: { provider: "openai", apiKey: "secret", baseUrl: "https://site/v1", model: "first" },
},
},
});
store.patch({ features: { voiceMode: { stt: { model: "second", baseUrl: null } } } });
await store.patch({ features: { voiceMode: { stt: { model: "second", baseUrl: null } } } });
expect(loadPersistedConfig(paseoHome).features?.voiceMode?.stt).toEqual({
provider: "openai",
apiKey: "secret",
model: "second",
});
store.patch({ features: { voiceMode: { stt: { provider: "openai", model: "third" } } } });
await store.patch({ features: { voiceMode: { stt: { provider: "openai", model: "third" } } } });
expect(store.get().features?.voiceMode.stt).toEqual({
provider: "openai",
model: "third",
hasApiKey: true,
});
store.patch({ features: { voiceMode: { stt: { apiKey: "new-secret" } } } });
store.patch({ features: { voiceMode: { stt: { provider: "local" } } } });
store.patch({ features: { voiceMode: { stt: { provider: "openai" } } } });
await store.patch({ features: { voiceMode: { stt: { apiKey: "new-secret" } } } });
await store.patch({ features: { voiceMode: { stt: { provider: "local" } } } });
await store.patch({ features: { voiceMode: { stt: { provider: "openai" } } } });
expect(store.get().features?.voiceMode.stt).toEqual({ provider: "openai", hasApiKey: false });
});
test("keeps a saved OpenAI key when a full variant patch omits the write-only key", () => {
test("keeps a saved OpenAI key when a full variant patch omits the write-only key", async () => {
const { paseoHome, store } = createSpeechStore({
features: { voiceMode: { stt: { provider: "openai", apiKey: "saved-secret" } } },
});
const result = store.patch({
const result = await store.patch({
features: { voiceMode: { stt: { provider: "openai", model: "whisper-1" } } },
});
expect(result.features?.voiceMode.stt).toEqual({
@@ -1471,7 +1513,7 @@ describe("DaemonConfigStore speech settings", () => {
expect(JSON.stringify(result)).not.toContain("saved-secret");
});
test("loads a legacy host, redacts its key and writes the normalized config after any save", () => {
test("loads a legacy host, redacts its key and writes the normalized config after any save", async () => {
const { paseoHome, store } = createSpeechStore({
providers: { openai: { apiKey: "legacy-secret", stt: { baseUrl: "https://old/v1" } } },
features: { voiceMode: { enabled: true, stt: { provider: "openai" } } },
@@ -1482,13 +1524,13 @@ describe("DaemonConfigStore speech settings", () => {
hasApiKey: true,
});
expect(JSON.stringify(store.get())).not.toContain("legacy-secret");
store.patch({ autoArchiveAfterMerge: true });
await store.patch({ autoArchiveAfterMerge: true });
const disk = JSON.parse(readFileSync(path.join(paseoHome, "config.json"), "utf8"));
expect(disk.providers).not.toHaveProperty("openai");
expect(disk.features.voiceMode.stt.apiKey).toBe("legacy-secret");
});
test("reports env overrides and removes fallback provenance after saving a key", () => {
test("reports env overrides and removes fallback provenance after saving a key", async () => {
const { store } = createSpeechStore(
{ features: { voiceMode: { stt: { provider: "openai", model: "configured" } } } },
{
@@ -1514,7 +1556,7 @@ describe("DaemonConfigStore speech settings", () => {
"features.voiceMode.stt.model": "STT_MODEL",
});
expect(other.get().features?.voiceMode.stt.hasApiKey).toBe(false);
other.patch({
await other.patch({
features: { voiceMode: { stt: { apiKey: "saved-secret", baseUrl: "https://saved/v1" } } },
});
expect(other.get().envOverrides).toEqual({ "features.voiceMode.stt.model": "STT_MODEL" });
@@ -1,6 +1,7 @@
import {
loadPersistedConfig,
savePersistedConfig,
savePersistedConfigAsync,
loadPersistedConfigAsync,
type PersistedConfig,
} from "./persisted-config.js";
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
@@ -335,6 +336,7 @@ export function applyMutableProviderConfigToOverrides(
}
export class DaemonConfigStore {
private mutations: Promise<unknown> = Promise.resolve();
private current: MutableDaemonConfig;
private readonly paseoHome: string;
private readonly logger: LoggerLike | undefined;
@@ -376,16 +378,29 @@ export class DaemonConfigStore {
return this.current;
}
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
const parsedPatch = pickSupportedPatchFields(MutableDaemonConfigPatchSchema.parse(partial));
return this.applySupportedPatch(parsedPatch);
public patch(
partial:
| MutableDaemonConfigPatch
| ((current: MutableDaemonConfig) => MutableDaemonConfigPatch),
): Promise<MutableDaemonConfig> {
return this.enqueue(() =>
this.applySupportedPatch(
pickSupportedPatchFields(
MutableDaemonConfigPatchSchema.parse(
typeof partial === "function" ? partial(this.current) : partial,
),
),
),
);
}
public setAgentSkillSelection(selection: AgentSkillSelection): MutableDaemonConfig {
return this.applySupportedPatch({ skills: { selection } });
public setAgentSkillSelection(selection: AgentSkillSelection): Promise<MutableDaemonConfig> {
return this.enqueue(() => this.applySupportedPatch({ skills: { selection } }));
}
private applySupportedPatch(parsedPatch: SupportedMutableConfigPatch): MutableDaemonConfig {
private async applySupportedPatch(
parsedPatch: SupportedMutableConfigPatch,
): Promise<MutableDaemonConfig> {
if (parsedPatch.relay?.enabled !== undefined && !this.relayEnabledMutable) {
throw new Error(
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
@@ -414,7 +429,7 @@ export class DaemonConfigStore {
return this.current;
}
const { previous: persistedBeforePatch, knownNext } = this.persistConfig(
const { previous: persistedBeforePatch, knownNext } = await this.persistConfig(
patch,
removedProviders,
);
@@ -423,7 +438,7 @@ export class DaemonConfigStore {
try {
this.applyReplacement(next, { removedProviders });
} catch (error) {
savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger);
await savePersistedConfigAsync(this.paseoHome, persistedBeforePatch, this.logger);
throw error;
}
}
@@ -454,12 +469,16 @@ export class DaemonConfigStore {
);
}
public reload(): DaemonConfigReloadResult {
public reload(): Promise<DaemonConfigReloadResult> {
return this.enqueue(() => this.reloadPersisted());
}
private async reloadPersisted(): Promise<DaemonConfigReloadResult> {
if (!this.reloadSource) {
throw new Error("Daemon config reload is unavailable for this daemon instance");
}
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
const persisted = await loadPersistedConfigAsync(this.paseoHome, this.logger);
const resolved = this.reloadSource.resolve(persisted);
// Plugin source changes require the plugin lifecycle operation or a daemon
// restart. The global switch is independently reloadable.
@@ -621,11 +640,24 @@ export class DaemonConfigStore {
};
}
private persistConfig(
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.mutations.then(operation);
this.mutations = result.catch(() => {});
return result;
}
public readServicePorts(): Promise<NonNullable<PersistedConfig["worktrees"]>["servicePorts"]> {
return this.enqueue(
async () =>
(await loadPersistedConfigAsync(this.paseoHome, this.logger)).worktrees?.servicePorts,
);
}
private async persistConfig(
patch: Omit<SupportedMutableConfigPatch, "removeProviders">,
removeProviders: readonly string[],
): { previous: PersistedConfig; knownNext: PersistedConfig } {
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
): Promise<{ previous: PersistedConfig; knownNext: PersistedConfig }> {
const persisted = await loadPersistedConfigAsync(this.paseoHome, this.logger);
const merge = (source: PersistedConfig) =>
mergeMutablePatchIntoPersistedConfig({
persisted: source,
@@ -635,7 +667,7 @@ export class DaemonConfigStore {
});
const nextPersisted = merge(persisted);
const knownNext = merge(this.lastKnownPersisted);
savePersistedConfig(this.paseoHome, nextPersisted, this.logger);
await savePersistedConfigAsync(this.paseoHome, nextPersisted, this.logger);
return { previous: persisted, knownNext };
}
}
@@ -1,6 +1,7 @@
import { execFileSync } from "node:child_process";
import { execFile, execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { rm } from "node:fs/promises";
import { rm, writeFile } from "node:fs/promises";
import { promisify } from "node:util";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
@@ -223,3 +224,76 @@ test("All changes subscriptions refresh on file edits and commits", async () =>
await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
}, 60_000);
test("large source diffs stay renderable for reads, subscriptions, and commit files", async () => {
const ctx = await createDaemonTestContext();
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-budget-"));
const runGit = promisify(execFile);
try {
git(cwd, "init", "-b", "main");
git(cwd, "config", "user.name", "Test");
git(cwd, "config", "user.email", "test@example.com");
git(cwd, "config", "core.autocrlf", "false");
const original = "const value = Math.max(42, 7);\n".repeat(Math.ceil((5 * 1024 * 1024) / 30));
writeFileSync(join(cwd, "generated.ts"), original);
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
await writeFile(join(cwd, "generated.ts"), original.replace("42", "43"));
const pending = ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
const rpcStarted = performance.now();
await ctx.client.fetchAgents({});
const rpcMs = performance.now() - rpcStarted;
const initial = await pending;
expect(initial.error).toBeNull();
const check = (file: (typeof initial.files)[number] | null | undefined, value: string) => {
expect(file).toMatchObject({ path: "generated.ts", additions: 1, deletions: 1 });
const lines = file!.hunks.flatMap((hunk) => hunk.lines);
expect(lines.filter((line) => line.type === "add").map((line) => line.content)).toEqual([
`const value = Math.max(${value}, 7);`,
]);
expect(lines.find((line) => line.type === "add")?.tokens).toContainEqual({
text: value,
style: "number",
});
};
check(initial.files[0], "43");
const subscription = ctx.client.observeCheckoutDiff(cwd, { mode: "uncommitted" });
try {
let latest = await subscription.ready;
check(latest.files[0], "43");
subscription.subscribe({
snapshot: (snapshot) => {
latest = snapshot;
},
update: (message) => {
if (message.type === "checkout_diff_update") latest = message.payload;
},
});
await writeFile(join(cwd, "generated.ts"), original.replace("42", "44"));
await expect
.poll(
() =>
latest.files[0]?.hunks.flatMap((hunk) => hunk.lines).find((line) => line.type === "add")
?.content,
{ timeout: 15_000 },
)
.toBe("const value = Math.max(44, 7);");
check(latest.files[0], "44");
} finally {
await subscription.release();
}
await runGit("git", ["add", "."], { cwd });
await runGit("git", ["-c", "commit.gpgsign=false", "commit", "-m", "edit"], { cwd });
const { stdout } = await runGit("git", ["rev-parse", "HEAD"], { cwd });
const commit = await ctx.client.getCommitFileDiff(cwd, stdout.trim(), "generated.ts");
check(commit.file, "44");
console.info({
sourceBytes: Buffer.byteLength(original),
cheapRpcMs: rpcMs,
isolatedDaemonPort: ctx.daemon.port,
});
} finally {
await ctx.cleanup();
await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
}, 60_000);
@@ -87,3 +87,53 @@ test("logs --follow preserves the same assistant text as one-shot logs", async (
await daemon.close();
}
}, 60_000);
test("omitted after limit pages forward while explicit zero preserves complete CLI history", async () => {
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome });
const initial = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
const cursor = { epoch: initial.epoch, seq: initial.window.maxSeq };
for (let index = 0; index < 451; index++) {
await daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: String(index),
});
}
const first = await client.fetchAgentTimeline(agent.id, { direction: "after", cursor });
expect(first.entries).toHaveLength(200);
expect(first.hasNewer).toBe(true);
expect(first.endCursor).toEqual({ epoch: cursor.epoch, seq: cursor.seq + 200 });
const second = await client.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: first.endCursor!,
});
const third = await client.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: second.endCursor!,
});
expect(second.entries).toHaveLength(200);
expect(third.entries).toHaveLength(51);
expect(third.hasNewer).toBe(false);
expect(
[...first.entries, ...second.entries, ...third.entries].map((entry) => entry.item),
).toEqual(
Array.from({ length: 451 }, (_, index) => ({ type: "user_message", text: String(index) })),
);
const allAfter = await client.fetchAgentTimeline(agent.id, {
direction: "after",
cursor,
limit: 0,
});
const allTail = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
expect(allAfter.entries).toHaveLength(451);
expect(allAfter.hasNewer).toBe(false);
expect(allTail.entries).toHaveLength(initial.entries.length + 451);
} finally {
await client.close();
await daemon.close();
}
}, 60_000);
@@ -0,0 +1,106 @@
import { realpath } from "node:fs";
import { readlink } from "node:fs/promises";
import { promisify } from "node:util";
import pLimit from "p-limit";
import { dirname, resolve as resolvePath, sep } from "node:path";
import { looksLikeDefiniteWindowsPath, normalizePathPreservingCase } from "../utils/path.js";
const nativeRealpath = promisify(realpath.native);
const jsRealpath = promisify(realpath);
const identityIo = pLimit(2);
/** A subscription owns its lexical namespace, including the OS's real-root alias. */
export class GitObservationPaths {
private constructor(
private readonly root: string,
private readonly aliases: string[],
private readonly windows: boolean,
) {}
static async create(root: string): Promise<GitObservationPaths> {
const windows = looksLikeDefiniteWindowsPath(root);
const normalizePath = (value: string) =>
windows
? normalizePathPreservingCase(value, true).replaceAll("\\", "/").toLowerCase()
: normalizePathPreservingCase(value, false);
const aliases = new Set([normalizePath(root)]);
// Sequential: foreign paths must not fan out into the shared libuv pool.
for (const resolve of [nativeRealpath, jsRealpath]) {
try {
aliases.add(normalizePath(await identityIo(() => resolve(root))));
} catch {
/* Missing roots retain lexical identity. */
}
}
// A link may name /var/... while realpath returns /private/var/... on macOS.
// Retain literal targets too, including chains, without following links in callbacks.
let current = root;
const visited = new Set<string>();
for (let remaining = 40; remaining > 0; remaining--) {
const alias = normalizePath(current);
if (visited.has(alias)) break;
visited.add(alias);
aliases.add(alias);
try {
const target = await identityIo(() => readlink(current));
current = resolvePath(dirname(current), target);
} catch {
break;
}
}
return new GitObservationPaths(root, [...aliases], windows);
}
relative(candidate: string): string | null {
const normalized = this.normalizeEventPath(candidate);
const comparable = this.windows ? normalized.toLowerCase() : normalized;
for (const alias of this.aliases) {
if (comparable === alias) return "";
const prefix = alias.endsWith("/") ? alias : `${alias}/`;
if (comparable.startsWith(prefix)) {
// Slice the original spelling, preserving case in ref names on Windows.
return normalized.slice(prefix.length);
}
}
return null;
}
// Compile excluded subtrees in every established root namespace. The event callback
// only compares strings; neither an ignored root nor an event needs another realpath.
createEventFilter(excludedRoots: Iterable<string>): (candidate: string) => boolean {
const excluded: string[] = [];
for (const root of excludedRoots) {
const relative = this.relative(root);
if (relative === null) continue;
for (const alias of this.aliases) {
const subtree = relative ? `${alias}/${relative}` : alias;
excluded.push(this.windows ? subtree.toLowerCase() : subtree);
}
}
const prefixes = excluded.map((root) => `${root}/`);
return (candidate) => {
const normalized = this.normalizeEventPath(candidate);
const comparable = this.windows ? normalized.toLowerCase() : normalized;
if (this.aliases.includes(comparable)) return false;
for (let i = 0; i < excluded.length; i++) {
if (comparable === excluded[i] || comparable.startsWith(prefixes[i])) return false;
}
return true;
};
}
private normalizeEventPath(candidate: string): string {
if (this.windows) return normalizePathPreservingCase(candidate, true).replaceAll("\\", "/");
if (candidate.includes("/.") || candidate.includes("//") || candidate.endsWith("/"))
return normalizePathPreservingCase(candidate, false);
return candidate;
}
matches = (candidate: string): boolean => this.relative(candidate) === "";
toWatchPath(candidate: string): string {
const relative = this.relative(candidate);
if (relative === null) return candidate;
return relative ? `${this.root}${sep}${relative}` : this.root;
}
}
@@ -57,7 +57,7 @@ describe("daemon agent skill selection", () => {
it("does not expose selection through the generic config patch path", async () => {
const { config, root, store } = await createStore();
config.patch({
await config.patch({
skills: { selection: { mode: "custom", skills: ["paseo"] } },
} as MutableDaemonConfigPatch);
@@ -43,7 +43,7 @@ export function createSkillSelectionStore(
},
async set(selection) {
const parsed = coerceSkillSelection(selection);
configStore.setAgentSkillSelection(parsed);
await configStore.setAgentSkillSelection(parsed);
return parsed;
},
async isSet() {
@@ -548,7 +548,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
);
expect(created.worktree.branchName).toBe("dazzling-yak");
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "pending",
@@ -575,7 +575,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
branchName: "renamed-from-agent-context",
});
expect(branchAfterFirst).toBe("renamed-from-agent-context");
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "attempted",
@@ -762,7 +762,7 @@ test("leaves the branch alone when generated branch text is invalid", async () =
.toString()
.trim(),
).toBe("dazzling-yak");
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "attempted",
@@ -794,7 +794,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
// baseRefName must be the repo's default branch, not the checked-out branch
// itself — otherwise the diff panel compares the branch against itself and
// always shows no changes.
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 1,
baseRefName: "main",
});
@@ -831,7 +831,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
createDeps(),
);
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 1,
baseRefName: "main",
});
@@ -77,7 +77,7 @@ async function createPaseoWorktreeWithPriority(
const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService);
const createdWorktree = await createWorktreeCore(input, deps);
try {
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
await maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({
relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd,
targetWorktreePath: createdWorktree.worktree.worktreePath,
@@ -182,9 +182,9 @@ export async function attemptFirstAgentBranchAutoName(options: {
return { attempted: false, renamed: false, branchName: null };
}
let metadata: ReturnType<typeof readPaseoWorktreeMetadata>;
let metadata: Awaited<ReturnType<typeof readPaseoWorktreeMetadata>>;
try {
metadata = readPaseoWorktreeMetadata(options.cwd);
metadata = await readPaseoWorktreeMetadata(options.cwd);
} catch {
return { attempted: false, renamed: false, branchName: null };
}
@@ -199,11 +199,11 @@ export async function attemptFirstAgentBranchAutoName(options: {
const getCurrentBranchImpl = options.getCurrentBranch ?? getCurrentBranch;
const placeholderBranchName = metadata.firstAgentBranchAutoName.placeholderBranchName;
if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) {
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
return { attempted: true, renamed: false, branchName: null };
}
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
const branchName = await options.generateBranchNameFromContext({
cwd: options.cwd,
@@ -264,15 +264,15 @@ async function findAvailableBranchName(options: {
return null;
}
function maybeMarkFirstAgentBranchAutoNameEligible(options: {
async function maybeMarkFirstAgentBranchAutoNameEligible(options: {
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
}): void {
}): Promise<void> {
const { createdWorktree } = options;
if (!createdWorktree.created || createdWorktree.intent.kind !== "branch-off") {
return;
}
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, {
await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, {
placeholderBranchName: createdWorktree.worktree.branchName,
});
}
@@ -16,9 +16,11 @@ import { describe, expect, test } from "vitest";
import {
loadPersistedConfig,
loadPersistedConfigAsync,
PersistedConfigSchema,
readPersistedConfig,
savePersistedConfig,
savePersistedConfigAsync,
} from "./persisted-config.js";
import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE } from "./private-files.js";
@@ -827,11 +829,14 @@ describe("readPersistedConfig with an unreadable config.json", () => {
});
});
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {
test("initializes config.json with private permissions", () => {
describe.skipIf(process.platform === "win32").each([
{ name: "startup", load: loadPersistedConfig, save: savePersistedConfig },
{ name: "runtime", load: loadPersistedConfigAsync, save: savePersistedConfigAsync },
])("persisted config file permissions ($name)", ({ load, save }) => {
test("initializes config.json with private permissions", async () => {
const home = createTempHome();
try {
loadPersistedConfig(home);
await load(home);
expect(modeOf(path.join(home, "config.json"))).toBe(PRIVATE_FILE_MODE);
} finally {
@@ -839,14 +844,14 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions
}
});
test("repairs permissive config.json permissions when loading", () => {
test("repairs permissive config.json permissions when loading", async () => {
const home = createTempHome();
const configPath = path.join(home, "config.json");
try {
writeFileSync(configPath, "{}\n", { mode: PERMISSIVE_FILE_MODE });
chmodSync(configPath, PERMISSIVE_FILE_MODE);
loadPersistedConfig(home);
await load(home);
expect(modeOf(configPath)).toBe(PRIVATE_FILE_MODE);
} finally {
@@ -854,11 +859,24 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions
}
});
test("saves config.json with private permissions", () => {
test("repairs unreadable owner permissions before reading config.json", async () => {
const home = createTempHome();
const configPath = path.join(home, "config.json");
try {
writeFileSync(configPath, "{}\n");
chmodSync(configPath, 0);
expect(await load(home)).toEqual({});
expect(modeOf(configPath)).toBe(PRIVATE_FILE_MODE);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("saves config.json with private permissions", async () => {
const parent = createTempHome();
const home = path.join(parent, "home");
try {
savePersistedConfig(home, {
await save(home, {
features: { dictation: { stt: { provider: "openai", apiKey: "secret" } } },
});
@@ -870,37 +888,37 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions
});
});
describe.skipIf(process.platform === "win32")(
"config.json symlinked from another directory",
() => {
test("saving writes through the symlink and leaves it in place", () => {
const parent = createTempHome();
const home = path.join(parent, "home");
const dotfiles = path.join(parent, "dotfiles");
const linkedConfigPath = path.join(dotfiles, "config.json");
const configPath = path.join(home, "config.json");
try {
mkdirSync(home);
mkdirSync(dotfiles, { mode: 0o755 });
chmodSync(dotfiles, 0o755);
writeFileSync(linkedConfigPath, '{ "version": 1 }\n');
symlinkSync(linkedConfigPath, configPath);
describe.skipIf(process.platform === "win32").each([
{ name: "startup", save: savePersistedConfig },
{ name: "runtime", save: savePersistedConfigAsync },
])("config.json symlinked from another directory ($name)", ({ save }) => {
test("saving writes through the symlink and leaves it in place", async () => {
const parent = createTempHome();
const home = path.join(parent, "home");
const dotfiles = path.join(parent, "dotfiles");
const linkedConfigPath = path.join(dotfiles, "config.json");
const configPath = path.join(home, "config.json");
try {
mkdirSync(home);
mkdirSync(dotfiles, { mode: 0o755 });
chmodSync(dotfiles, 0o755);
writeFileSync(linkedConfigPath, '{ "version": 1 }\n');
symlinkSync(linkedConfigPath, configPath);
savePersistedConfig(home, { version: 1, daemon: { relay: { enabled: false } } });
await save(home, { version: 1, daemon: { relay: { enabled: false } } });
expect(lstatSync(configPath).isSymbolicLink()).toBe(true);
expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({
version: 1,
daemon: { relay: { enabled: false } },
});
expect(modeOf(dotfiles)).toBe(0o755);
expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE);
} finally {
rmSync(parent, { recursive: true, force: true });
}
});
},
);
expect(lstatSync(configPath).isSymbolicLink()).toBe(true);
expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({
version: 1,
daemon: { relay: { enabled: false } },
});
expect(modeOf(dotfiles)).toBe(0o755);
expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE);
} finally {
rmSync(parent, { recursive: true, force: true });
}
});
});
describe("speech provider config normalization", () => {
test.each([undefined, "local"])(
+73 -9
View File
@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises";
import { PluginRegistriesSchema } from "@getpaseo/protocol/plugin-registry";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
@@ -9,7 +10,12 @@ import {
ProviderOverridesSchema,
} from "./agent/provider-launch-config.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
import {
ensurePrivateFile,
ensurePrivateFileAsync,
writePrivateFileAtomic,
writePrivateFileAtomicSync,
} from "./private-files.js";
import { AgentProfileSchema, AgentSkillSelectionSchema } from "@getpaseo/protocol/agent-profile";
import { PluginIdSchema, PluginSourceSchema } from "@getpaseo/protocol/plugin-config";
import { TerminalProfileSchema } from "@getpaseo/protocol/terminal-profile";
@@ -523,6 +529,40 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per
return config;
}
/** Runtime config IO. Startup and offline CLI consumers retain their synchronous entry points. */
export async function loadPersistedConfigAsync(
paseoHome: string,
logger?: LoggerLike,
): Promise<PersistedConfig> {
const log = getLogger(logger);
const configPath = getConfigPath(paseoHome);
let raw: string;
await ensurePrivateFileAsync(configPath);
try {
raw = await readFile(configPath, "utf-8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw new Error(
`[Config] Failed to read ${configPath}: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
try {
raw = JSON.stringify(DEFAULT_PERSISTED_CONFIG, null, 2) + "\n";
await writePrivateFileAtomic(configPath, raw);
log?.info(`Initialized config file at ${configPath}`);
} catch (initializationError) {
throw new Error(
`[Config] Failed to initialize ${configPath}: ${initializationError instanceof Error ? initializationError.message : String(initializationError)}`,
{ cause: initializationError },
);
}
}
const config = parseConfigFile(configPath, raw, log);
log?.info(`Loaded from ${configPath}`);
return config;
}
/** Observe the file without initializing a home, identity, or default configuration. */
export function readPersistedConfig(
paseoHome: string,
@@ -642,16 +682,10 @@ export function savePersistedConfig(
const log = getLogger(logger);
const configPath = getConfigPath(paseoHome);
const result = PersistedConfigSchema.safeParse(config);
if (!result.success) {
const issues = result.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`[Config] Invalid config to save:\n${issues}`);
}
const data = serializePersistedConfig(config);
try {
writePrivateFileAtomicSync(configPath, JSON.stringify(result.data, null, 2) + "\n");
writePrivateFileAtomicSync(configPath, data);
log?.info(`Saved to ${configPath}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -660,3 +694,33 @@ export function savePersistedConfig(
});
}
}
function serializePersistedConfig(config: PersistedConfig): string {
const result = PersistedConfigSchema.safeParse(config);
if (!result.success) {
const issues = result.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`[Config] Invalid config to save:\n${issues}`);
}
return JSON.stringify(result.data, null, 2) + "\n";
}
export async function savePersistedConfigAsync(
paseoHome: string,
config: PersistedConfig,
logger?: LoggerLike,
): Promise<void> {
const configPath = getConfigPath(paseoHome);
const data = serializePersistedConfig(config);
try {
await writePrivateFileAtomic(configPath, data);
getLogger(logger)?.info(`Saved to ${configPath}`);
} catch (error) {
throw new Error(
`[Config] Failed to write ${configPath}: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
}
@@ -0,0 +1,431 @@
import { stat } from "node:fs/promises";
import { readPluginManifest } from "./manifest.js";
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { isBuiltin } from "node:module";
import path from "node:path";
import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js";
import type { Metafile, OnResolveResult, Plugin } from "esbuild";
import {
isPluginClientOnlySdkSpecifier,
isPluginServerOnlySdkSpecifier,
PLUGIN_SDK_SPECIFIERS,
} from "./plugin-sdk-specifiers.js";
import { loadEsbuild, SERVER_HOST_MODULES } from "./compiler-esbuild.js";
type PluginBuildTarget = "client" | "server";
type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid";
function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null {
const relative = path.relative(pluginDirectory, filePath);
if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid";
const segments = relative.split(path.sep);
if (segments.includes("node_modules")) return null;
if (segments[0] === "client") return "client";
if (segments[0] === "server") return "server";
if (segments[0] === "shared") return "shared";
if (/^index\.client\.tsx?$/.test(relative)) return "client";
if (/^index\.server\.tsx?$/.test(relative)) return "server";
return "invalid";
}
function containsPath(directory: string, filePath: string): boolean {
const relative = path.relative(directory, filePath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function dependencyName(specifier: string): string {
const segments = specifier.split("/");
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
}
function findDependencyRoot(
resolvedPath: string,
specifier: string,
pluginDirectory: string,
): string | null {
const expectedName = dependencyName(specifier);
const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`;
let directory = path.dirname(resolvedPath);
for (;;) {
const manifestPath = path.join(directory, "package.json");
if (existsSync(manifestPath)) {
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown };
if (manifest.name === expectedName || manifest.name === ambientName) {
if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) {
return null;
}
return directory;
}
}
const parent = path.dirname(directory);
if (parent === directory) return null;
directory = parent;
}
}
function moduleBoundaryError(
moduleLocation: PluginModuleLocation | null,
target: PluginBuildTarget | "shared",
filePath: string,
): OnResolveResult | null {
if (moduleLocation === "invalid") {
return {
errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }],
};
}
if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) {
return null;
}
return {
errors: [
{
text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`,
},
],
};
}
function lexicalBoundaryError(
specifier: string,
resolveDirectory: string,
pluginDirectory: string,
target: PluginBuildTarget | "shared",
): OnResolveResult | null {
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null;
const lexicalPath = path.resolve(resolveDirectory, specifier);
if (containsPath(pluginDirectory, lexicalPath)) {
return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath);
}
// Normalize only a containing root alias. Resolving the whole import would erase
// an authored server/ or client/ location when the final file is a symlink.
for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) {
if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) {
const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath));
return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath);
}
if (path.dirname(ancestor) === ancestor) return null;
}
}
function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin {
const boundaryResolution = {};
const linkedDependencyRoots = new Set<string>();
return {
name: `paseo-plugin-${target}-runtime-boundary`,
setup(buildContext) {
const checked = new Set<string>();
const imports = createPluginImportReader(pluginDirectory);
function resolvedBoundaryError(
resolvedFile: string,
specifier: string,
importer: string,
owner: PluginBuildTarget | "shared",
) {
const file = realpathSync.native(resolvedFile);
const location = directoryTarget(file, pluginDirectory);
if (location === "invalid") {
if (
[...linkedDependencyRoots].some(
(root) => containsPath(root, importer) && containsPath(root, file),
)
)
return null;
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) {
const root = findDependencyRoot(file, specifier, pluginDirectory);
if (root) {
linkedDependencyRoots.add(root);
return null;
}
}
}
return moduleBoundaryError(location, owner, file);
}
async function resolveImportFiles(
file: string,
specifier: string,
kind: PluginImportKind,
typeOnly: boolean,
): Promise<Set<string>> {
const declaration = imports.resolve(specifier, file, kind);
const dependencyFiles = new Set<string>();
if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration)))
dependencyFiles.add(declaration);
if (typeOnly && !declaration) {
throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`);
}
if (!typeOnly && kind !== "type-reference") {
const resolution = await buildContext.resolve(specifier, {
importer: file,
resolveDir: path.dirname(file),
kind,
});
// esbuild owns missing-runtime errors: erased imports and guarded optional
// requires are legal. Inspect every dependency that actually resolves.
if (!resolution.errors.length && !resolution.external && resolution.namespace === "file")
dependencyFiles.add(realpathSync.native(resolution.path));
}
return dependencyFiles;
}
async function checkSourceImports(
sourcePath: string,
inheritedOwner: PluginBuildTarget | "shared",
): Promise<OnResolveResult | null> {
const file = realpathSync.native(sourcePath);
const owner =
directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner;
const key = `${owner}:${file}`;
if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null;
checked.add(key);
for (const { specifier, kind, typeOnly } of imports.read(file)) {
const packageSpecifier =
kind === "type-reference"
? `@types/${specifier.replace(/^@/, "").replace("/", "__")}`
: specifier;
const error =
runtimeSpecifierError(specifier, owner, file) ??
runtimeSpecifierError(packageSpecifier, owner, file) ??
lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner);
if (error) return error;
// Protocol types are shared host contracts, also exposed by the SDK's peer
// dependency during development. Erased imports need no plugin-local install;
// runtime imports still go through normal dependency resolution below.
if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue;
// Host modules have separately enforced SDK boundaries and need no local installation.
if (
(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) ||
/^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) ||
isBuiltin(specifier) ||
packageSpecifier === "@types/node"
)
continue;
const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly);
for (const dependencyFile of dependencyFiles) {
const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner);
if (boundaryError) return boundaryError;
const dependencyError = await checkSourceImports(dependencyFile, owner);
if (dependencyError) return dependencyError;
}
}
return null;
}
buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) =>
checkSourceImports(args.path, target),
);
buildContext.onResolve({ filter: /.*/ }, async (args) => {
if (args.kind === "entry-point") return null;
if (args.pluginData === boundaryResolution) return null;
const importer =
args.namespace === "file" ? realpathSync.native(args.importer) : args.importer;
const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir;
const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target;
const specifierError = runtimeSpecifierError(args.path, owner, importer);
if (specifierError) return specifierError;
const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner);
if (lexicalError) return lexicalError;
const resolution = await buildContext.resolve(args.path, {
importer,
namespace: args.namespace,
resolveDir,
kind: args.kind,
pluginData: boundaryResolution,
with: args.with,
});
if (
resolution.errors.length > 0 ||
resolution.external ||
resolution.namespace !== "file"
) {
return null;
}
return resolvedBoundaryError(resolution.path, args.path, importer, owner);
});
},
};
}
function wrapCommonJsBundle(code: string): string {
return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`;
}
function makeHermesInteropEager(code: string): string {
// Hermes evaluates esbuild's lazy CommonJS interop getters from a string with
// the final loop binding, so every named import can resolve to the last export.
// Plugin bundles execute once and do not need live bindings from host modules.
return code.replaceAll("get: () => from[key]", "value: from[key]");
}
function runtimeSpecifierError(
specifier: string,
target: PluginBuildTarget | "shared",
importer: string,
): OnResolveResult | null {
let kind: string | null = null;
if (specifier === "@getpaseo/plugin/client/host") kind = "host-private";
else if (
(specifier === "@getpaseo/plugin" ||
specifier.startsWith("@getpaseo/plugin/") ||
specifier === "@paseo/plugin" ||
specifier.startsWith("@paseo/plugin/")) &&
!(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier)
)
kind = "Unknown SDK";
else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node"))
kind = "Node";
else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only";
else if (
target !== "client" &&
(isPluginClientOnlySdkSpecifier(specifier) ||
/^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test(
specifier,
))
)
kind = "client-only";
return kind
? {
errors: [
{
text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`,
},
],
}
: null;
}
function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void {
function inputLocation(file: string): PluginModuleLocation | null {
const absolutePath = path.resolve(file);
// Metafile keys must stay unchanged for graph traversal. Only filesystem
// inputs have canonical paths; data URLs and external specifiers do not.
return directoryTarget(
existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath,
pluginDirectory,
);
}
const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared");
const visited = new Set<string>();
while (pending.length) {
const file = pending.pop()!;
if (visited.has(file)) continue;
visited.add(file);
for (const dependency of inputs[file]?.imports ?? []) {
const location = dependency.external ? null : inputLocation(dependency.path);
const error =
runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ??
(dependency.external
? null
: moduleBoundaryError(
location === "invalid" ? null : location,
"shared",
dependency.path,
));
if (error?.errors?.length) throw new Error(error.errors[0].text);
if (!dependency.external) pending.push(dependency.path);
}
}
}
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
const { build } = loadEsbuild();
// Use native canonical paths throughout: TypeScript expands Windows short names
// when resolving type references, while the JS realpath implementation retains them.
const pluginDirectory = realpathSync.native(path.dirname(entryPath));
const result = await build({
entryPoints: [realpathSync.native(entryPath)],
bundle: true,
format: "cjs",
jsx: "automatic",
// `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon.
loader: { ".svg": "text" },
platform: target === "server" ? "node" : "neutral",
target: target === "server" ? "node20" : "es2020",
// The neutral platform reads no package.json entry fields, so packages without
// `exports` would not resolve. Read the same fields as esbuild's browser platform
// minus `browser`, since the bundle also runs in React Native.
mainFields: target === "client" ? ["module", "main"] : undefined,
// Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro,
// so apply the same compatibility transform before the app evaluates them from source.
supported: target === "client" ? { "async-await": false } : undefined,
external:
target === "client"
? [
...PLUGIN_SDK_SPECIFIERS,
"@tanstack/react-query",
"react",
"react/jsx-runtime",
"react-native",
"zod",
]
: SERVER_HOST_MODULES,
plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)],
metafile: true,
logLevel: "silent",
treeShaking: true,
write: false,
});
checkSharedDependencies(result.metafile.inputs, pluginDirectory);
const output = result.outputFiles[0]?.text;
if (!output) throw new Error(`Plugin ${target} compilation produced no output`);
return wrapCommonJsBundle(makeHermesInteropEager(output));
}
export async function compilePlugin(entryPaths: {
client: string | null;
server: string | null;
}): Promise<{
clientBundle: string | null;
serverBundle: string | null;
}> {
const [clientBundle, serverBundle] = await Promise.all([
entryPaths.client ? compileTarget(entryPaths.client, "client") : null,
entryPaths.server ? compileTarget(entryPaths.server, "server") : null,
]);
return { clientBundle, serverBundle };
}
const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const;
const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const;
async function findEntry(directory: string, filenames: readonly string[]): Promise<string | null> {
for (const filename of filenames) {
const filePath = path.join(directory, filename);
const info = await stat(filePath).catch(() => null);
if (info?.isFile()) return filePath;
}
return null;
}
async function resolveEntryPaths(directory: string): Promise<{
client: string | null;
server: string | null;
}> {
const [client, server] = await Promise.all([
findEntry(directory, CLIENT_ENTRY_FILENAMES),
findEntry(directory, SERVER_ENTRY_FILENAMES),
]);
if (client || server) return { client, server };
const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]);
if (legacyEntry) {
throw new Error(
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration",
);
}
throw new Error(
`Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`,
);
}
export async function compileDirectoryPlugin(
directory: string,
version: string,
builtinId?: string,
) {
const manifest = await readPluginManifest(directory);
if (builtinId !== undefined && manifest.id !== builtinId) {
throw new Error(`Built-in plugin ${builtinId} has manifest ID ${manifest.id}`);
}
assertPluginCompatibility({ ...manifest, version, runtime: "daemon" });
const bundles = await compilePlugin(await resolveEntryPaths(directory));
return { ...bundles, manifest };
}
@@ -0,0 +1,64 @@
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { PLUGIN_SDK_SPECIFIERS } from "./plugin-sdk-specifiers.js";
const nodeRequire = createRequire(import.meta.url);
export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"];
const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH";
// esbuild resolves its own platform binary via require.resolve() the first time its
// module is evaluated. Inside the packaged desktop app that resolves to a path under
// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked.
// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path
// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads.
export function unpackedEsbuildBinaryFromPackageDir(
esbuildPackageDir: string,
platform: NodeJS.Platform,
arch: string,
): string | null {
const asarSegment = `${path.sep}app.asar${path.sep}`;
const asarIndex = esbuildPackageDir.indexOf(asarSegment);
if (asarIndex === -1) return null;
return path.join(
esbuildPackageDir.slice(0, asarIndex),
"app.asar.unpacked",
"node_modules",
`@esbuild/${platform}-${arch}`,
...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]),
);
}
export function resolveExistingAsarUnpackedEsbuildBinary(
esbuildPackageDir: string,
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
exists: (file: string) => boolean = existsSync,
): string | null {
const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch);
return binaryPath && exists(binaryPath) ? binaryPath : null;
}
function resolveAsarUnpackedEsbuildBinary(): string | null {
let esbuildDir: string;
try {
esbuildDir = path.dirname(nodeRequire.resolve("esbuild/package.json"));
} catch {
return null;
}
return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir);
}
export function loadEsbuild(): typeof import("esbuild") {
const previousBinaryPath = process.env[ESBUILD_BINARY_PATH];
const unpackedBinary = resolveAsarUnpackedEsbuildBinary();
if (unpackedBinary) process.env[ESBUILD_BINARY_PATH] = unpackedBinary;
try {
// esbuild reads this variable while its CommonJS module is evaluated. Keep
// the compatibility bridge local so it cannot become an agent's environment.
return nodeRequire("esbuild") as typeof import("esbuild");
} finally {
if (previousBinaryPath === undefined) delete process.env[ESBUILD_BINARY_PATH];
else process.env[ESBUILD_BINARY_PATH] = previousBinaryPath;
}
}
@@ -0,0 +1,62 @@
import { parentPort } from "node:worker_threads";
import type { ZodIssue } from "zod";
import { compilePlugin, compileDirectoryPlugin } from "./compiler-build.js";
import { runPluginBuild } from "./preparation-build.js";
import type { CompilerTask } from "./compiler.js";
export type CompilerReply =
| { kind: "log"; data: Record<string, unknown>; message: string }
| { kind: "result"; value: unknown }
| {
kind: "error";
error: Error;
properties: Record<string, unknown>;
issues?: ZodIssue[];
errors?: unknown[];
warnings?: unknown[];
};
const port = parentPort!;
// The compiler owner serializes requests; the port queues the first request
// until this entrypoint has loaded, including in source/tsx development.
port.on("message", (task: CompilerTask) => {
void run(task);
});
async function run(task: CompilerTask): Promise<void> {
try {
let value: unknown;
switch (task.kind) {
case "entries":
value = await compilePlugin(task.entries);
break;
case "directory":
value = await compileDirectoryPlugin(task.directory, task.version, task.builtinId);
break;
case "prepare":
await runPluginBuild(
task.directory,
task.commands,
{
info: (data, message) =>
port.postMessage({ kind: "log", data, message } satisfies CompilerReply),
},
task.environment,
);
break;
}
port.postMessage({ kind: "result", value } satisfies CompilerReply);
} catch (cause) {
const error = cause instanceof Error ? cause : new Error(String(cause));
port.postMessage({
kind: "error",
error,
// Error cloning drops own metadata such as filesystem code/path. Zod's
// readonly issues are reconstructed separately by the compiler owner.
properties: Object.fromEntries(Object.entries(error).filter(([key]) => key !== "issues")),
...("issues" in error ? { issues: error.issues as ZodIssue[] } : {}),
...("errors" in error ? { errors: error.errors as unknown[] } : {}),
...("warnings" in error ? { warnings: error.warnings as unknown[] } : {}),
} satisfies CompilerReply);
}
}
@@ -3,8 +3,10 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ZodError } from "zod";
import {
compilePlugin,
compileDirectoryPlugin,
resolveExistingAsarUnpackedEsbuildBinary,
unpackedEsbuildBinaryFromPackageDir,
} from "./compiler.js";
@@ -975,3 +977,52 @@ export default function contribute() { void value; void secret; return () => und
expect(serverBundle).toBeNull();
});
});
it("preserves esbuild diagnostics across the compiler boundary", async () => {
const { server } = await createSplitPlugin();
await writeFile(server, "export default function contribute( {");
const error = await compilePlugin({ client: null, server }).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect(error).toMatchObject({
name: "Error",
message: expect.stringContaining("Expected identifier but found end of file"),
errors: [
expect.objectContaining({
text: "Expected identifier but found end of file",
location: expect.objectContaining({
line: 1,
lineText: "export default function contribute( {",
}),
}),
],
warnings: [],
});
// A failed task does not poison the next build.
await writeFile(server, 'export default () => "recovered";');
const bundles = await compilePlugin({ client: null, server });
expect(bundles.serverBundle).toContain("recovered");
});
it("preserves manifest validation errors across the compiler boundary", async () => {
const { directory } = await createSplitPlugin();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: 42 }));
const error = await compileDirectoryPlugin(directory, "0.13.2").catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(ZodError);
expect(error).toMatchObject({
name: "ZodError",
issues: [expect.objectContaining({ path: ["id"], code: "invalid_type" })],
});
await writeFile(path.join(directory, "paseo-plugin.json"), "{");
await expect(compileDirectoryPlugin(directory, "0.13.2")).rejects.toBeInstanceOf(SyntaxError);
});
it("preserves filesystem error metadata for a missing entry", async () => {
const { server } = await createSplitPlugin();
await rm(server);
await expect(compilePlugin({ client: null, server })).rejects.toMatchObject({
code: "ENOENT",
errno: expect.any(Number),
syscall: "realpath",
path: server,
});
});
+137 -427
View File
@@ -1,442 +1,152 @@
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { createRequire, isBuiltin } from "node:module";
import path from "node:path";
import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js";
import type { Metafile, OnResolveResult, Plugin } from "esbuild";
import {
isPluginClientOnlySdkSpecifier,
isPluginServerOnlySdkSpecifier,
PLUGIN_SDK_SPECIFIERS,
} from "./plugin-sdk-specifiers.js";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import { Worker } from "node:worker_threads";
import pLimit from "p-limit";
import { ZodError } from "zod";
import type * as build from "./compiler-build.js";
import type { PluginManifest } from "./manifest.js";
import type { CompilerReply } from "./compiler-worker.js";
const nodeRequire = createRequire(import.meta.url);
export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"];
const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH";
export {
SERVER_HOST_MODULES,
resolveExistingAsarUnpackedEsbuildBinary,
unpackedEsbuildBinaryFromPackageDir,
} from "./compiler-esbuild.js";
// esbuild resolves its own platform binary via require.resolve() the first time its
// module is evaluated. Inside the packaged desktop app that resolves to a path under
// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked.
// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path
// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads.
export function unpackedEsbuildBinaryFromPackageDir(
esbuildPackageDir: string,
platform: NodeJS.Platform,
arch: string,
): string | null {
const asarSegment = `${path.sep}app.asar${path.sep}`;
const asarIndex = esbuildPackageDir.indexOf(asarSegment);
if (asarIndex === -1) return null;
return path.join(
esbuildPackageDir.slice(0, asarIndex),
"app.asar.unpacked",
"node_modules",
`@esbuild/${platform}-${arch}`,
...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]),
);
}
export function resolveExistingAsarUnpackedEsbuildBinary(
esbuildPackageDir: string,
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
exists: (file: string) => boolean = existsSync,
): string | null {
const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch);
return binaryPath && exists(binaryPath) ? binaryPath : null;
}
function resolveAsarUnpackedEsbuildBinary(): string | null {
let esbuildDir: string;
try {
esbuildDir = path.dirname(nodeRequire.resolve("esbuild/package.json"));
} catch {
return null;
}
return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir);
}
function loadEsbuild(): typeof import("esbuild") {
const previousBinaryPath = process.env[ESBUILD_BINARY_PATH];
const unpackedBinary = resolveAsarUnpackedEsbuildBinary();
if (unpackedBinary) process.env[ESBUILD_BINARY_PATH] = unpackedBinary;
try {
// esbuild reads this variable while its CommonJS module is evaluated. Keep
// the compatibility bridge local so it cannot become an agent's environment.
return nodeRequire("esbuild") as typeof import("esbuild");
} finally {
if (previousBinaryPath === undefined) delete process.env[ESBUILD_BINARY_PATH];
else process.env[ESBUILD_BINARY_PATH] = previousBinaryPath;
}
}
type PluginBuildTarget = "client" | "server";
type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid";
function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null {
const relative = path.relative(pluginDirectory, filePath);
if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid";
const segments = relative.split(path.sep);
if (segments.includes("node_modules")) return null;
if (segments[0] === "client") return "client";
if (segments[0] === "server") return "server";
if (segments[0] === "shared") return "shared";
if (/^index\.client\.tsx?$/.test(relative)) return "client";
if (/^index\.server\.tsx?$/.test(relative)) return "server";
return "invalid";
}
function containsPath(directory: string, filePath: string): boolean {
const relative = path.relative(directory, filePath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function dependencyName(specifier: string): string {
const segments = specifier.split("/");
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
}
function findDependencyRoot(
resolvedPath: string,
specifier: string,
pluginDirectory: string,
): string | null {
const expectedName = dependencyName(specifier);
const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`;
let directory = path.dirname(resolvedPath);
for (;;) {
const manifestPath = path.join(directory, "package.json");
if (existsSync(manifestPath)) {
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown };
if (manifest.name === expectedName || manifest.name === ambientName) {
if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) {
return null;
}
return directory;
}
}
const parent = path.dirname(directory);
if (parent === directory) return null;
directory = parent;
}
}
function moduleBoundaryError(
moduleLocation: PluginModuleLocation | null,
target: PluginBuildTarget | "shared",
filePath: string,
): OnResolveResult | null {
if (moduleLocation === "invalid") {
return {
errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }],
export type CompilerTask =
| { kind: "entries"; entries: Parameters<typeof build.compilePlugin>[0] }
| { kind: "directory"; directory: string; version: string; builtinId?: string }
| {
kind: "prepare";
directory: string;
commands: PluginManifest["build"];
environment: NodeJS.ProcessEnv;
};
}
if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) {
return null;
}
return {
errors: [
{
text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`,
},
],
};
interface TaskResult {
entries: Awaited<ReturnType<typeof build.compilePlugin>>;
directory: Awaited<ReturnType<typeof build.compileDirectoryPlugin>>;
prepare: void;
}
function lexicalBoundaryError(
specifier: string,
resolveDirectory: string,
pluginDirectory: string,
target: PluginBuildTarget | "shared",
): OnResolveResult | null {
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null;
const lexicalPath = path.resolve(resolveDirectory, specifier);
if (containsPath(pluginDirectory, lexicalPath)) {
return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath);
}
// Normalize only a containing root alias. Resolving the whole import would erase
// an authored server/ or client/ location when the final file is a symlink.
for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) {
if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) {
const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath));
return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath);
}
if (path.dirname(ancestor) === ancestor) return null;
}
}
const IDLE_TIMEOUT_MS = 1_000;
const source = import.meta.url.endsWith(".ts");
const workerUrl = new URL(
source ? "./compiler-worker.ts" : "./compiler-worker.js",
import.meta.url,
);
// Resolve the development loader once at module initialization, never during a
// compile request. Packaged workers execute ordinary JavaScript without tsx.
const workerEntrypoint = source
? `import(${JSON.stringify(pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm/api")).href)}).then(({ register }) => { register(); return import(${JSON.stringify(workerUrl.href)}); });`
: workerUrl;
function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin {
const boundaryResolution = {};
const linkedDependencyRoots = new Set<string>();
return {
name: `paseo-plugin-${target}-runtime-boundary`,
setup(buildContext) {
const checked = new Set<string>();
const imports = createPluginImportReader(pluginDirectory);
function resolvedBoundaryError(
resolvedFile: string,
specifier: string,
importer: string,
owner: PluginBuildTarget | "shared",
) {
const file = realpathSync.native(resolvedFile);
const location = directoryTarget(file, pluginDirectory);
if (location === "invalid") {
if (
[...linkedDependencyRoots].some(
(root) => containsPath(root, importer) && containsPath(root, file),
)
)
return null;
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) {
const root = findDependencyRoot(file, specifier, pluginDirectory);
if (root) {
linkedDependencyRoots.add(root);
return null;
}
// Reuse the isolate during a build batch (notably daemon bootstrap), then release
// TypeScript's heap when idle. Only this owner admits work or stops the worker.
class PluginCompiler {
private readonly queue = pLimit(1);
private worker: Worker | null = null;
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private stopping: Promise<unknown> = Promise.resolve();
run<T extends CompilerTask>(
task: T,
onLog?: (data: Record<string, unknown>, message: string) => void,
): Promise<TaskResult[T["kind"]]> {
return this.queue(async () => {
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = null;
await this.stopping;
const worker = this.worker ?? this.startWorker();
worker.ref();
try {
return await this.request<T>(worker, task, onLog);
} finally {
if (this.worker === worker) {
worker.unref();
this.idleTimer = setTimeout(() => this.stopWorker(worker), IDLE_TIMEOUT_MS);
this.idleTimer.unref();
} else {
await this.stopping;
}
}
});
}
private startWorker(): Worker {
const worker = new Worker(workerEntrypoint, { eval: source, execArgv: [] });
this.worker = worker;
// Keep error/exit ownership while idle too. The next caller gets a fresh
// isolate after a crash; author build commands are never retried.
worker.on("error", () => this.stopWorker(worker));
worker.on("exit", () => {
if (this.worker === worker) this.stopWorker(worker);
});
return worker;
}
private stopWorker(worker: Worker): void {
if (this.worker !== worker) return;
this.worker = null;
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = null;
this.stopping = worker.terminate();
}
private async request<T extends CompilerTask>(
worker: Worker,
task: T,
onLog?: (data: Record<string, unknown>, message: string) => void,
): Promise<TaskResult[T["kind"]]> {
let onMessage: (reply: CompilerReply) => void;
let onError: (error: Error) => void;
let onExit: (code: number) => void;
try {
return await new Promise<TaskResult[T["kind"]]>((resolve, reject) => {
onError = reject;
onExit = (code) => reject(new Error(`Plugin compiler worker exited (code ${code})`));
onMessage = (reply) => {
if (reply.kind === "log") {
onLog?.(reply.data, reply.message);
} else if (reply.kind === "result") {
resolve(reply.value as TaskResult[T["kind"]]);
} else {
const error = reply.issues ? new ZodError(reply.issues) : reply.error;
Object.assign(error, reply.properties);
if (reply.errors) Object.assign(error, { errors: reply.errors });
if (reply.warnings) Object.assign(error, { warnings: reply.warnings });
// Promise.all can reject while the other target is still compiling.
// Retire the isolate before another request can enter it.
this.stopWorker(worker);
reject(error);
}
}
return moduleBoundaryError(location, owner, file);
}
async function resolveImportFiles(
file: string,
specifier: string,
kind: PluginImportKind,
typeOnly: boolean,
): Promise<Set<string>> {
const declaration = imports.resolve(specifier, file, kind);
const dependencyFiles = new Set<string>();
if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration)))
dependencyFiles.add(declaration);
if (typeOnly && !declaration) {
throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`);
}
if (!typeOnly && kind !== "type-reference") {
const resolution = await buildContext.resolve(specifier, {
importer: file,
resolveDir: path.dirname(file),
kind,
});
// esbuild owns missing-runtime errors: erased imports and guarded optional
// requires are legal. Inspect every dependency that actually resolves.
if (!resolution.errors.length && !resolution.external && resolution.namespace === "file")
dependencyFiles.add(realpathSync.native(resolution.path));
}
return dependencyFiles;
}
async function checkSourceImports(
sourcePath: string,
inheritedOwner: PluginBuildTarget | "shared",
): Promise<OnResolveResult | null> {
const file = realpathSync.native(sourcePath);
const owner =
directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner;
const key = `${owner}:${file}`;
if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null;
checked.add(key);
for (const { specifier, kind, typeOnly } of imports.read(file)) {
const packageSpecifier =
kind === "type-reference"
? `@types/${specifier.replace(/^@/, "").replace("/", "__")}`
: specifier;
const error =
runtimeSpecifierError(specifier, owner, file) ??
runtimeSpecifierError(packageSpecifier, owner, file) ??
lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner);
if (error) return error;
// Protocol types are shared host contracts, also exposed by the SDK's peer
// dependency during development. Erased imports need no plugin-local install;
// runtime imports still go through normal dependency resolution below.
if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue;
// Host modules have separately enforced SDK boundaries and need no local installation.
if (
(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) ||
/^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) ||
isBuiltin(specifier) ||
packageSpecifier === "@types/node"
)
continue;
const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly);
for (const dependencyFile of dependencyFiles) {
const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner);
if (boundaryError) return boundaryError;
const dependencyError = await checkSourceImports(dependencyFile, owner);
if (dependencyError) return dependencyError;
}
}
return null;
}
buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) =>
checkSourceImports(args.path, target),
);
buildContext.onResolve({ filter: /.*/ }, async (args) => {
if (args.kind === "entry-point") return null;
if (args.pluginData === boundaryResolution) return null;
const importer =
args.namespace === "file" ? realpathSync.native(args.importer) : args.importer;
const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir;
const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target;
const specifierError = runtimeSpecifierError(args.path, owner, importer);
if (specifierError) return specifierError;
const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner);
if (lexicalError) return lexicalError;
const resolution = await buildContext.resolve(args.path, {
importer,
namespace: args.namespace,
resolveDir,
kind: args.kind,
pluginData: boundaryResolution,
with: args.with,
});
if (
resolution.errors.length > 0 ||
resolution.external ||
resolution.namespace !== "file"
) {
return null;
}
return resolvedBoundaryError(resolution.path, args.path, importer, owner);
};
worker.on("error", onError);
worker.on("exit", onExit);
worker.on("message", onMessage);
worker.postMessage(task, []);
});
},
};
}
function wrapCommonJsBundle(code: string): string {
return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`;
}
function makeHermesInteropEager(code: string): string {
// Hermes evaluates esbuild's lazy CommonJS interop getters from a string with
// the final loop binding, so every named import can resolve to the last export.
// Plugin bundles execute once and do not need live bindings from host modules.
return code.replaceAll("get: () => from[key]", "value: from[key]");
}
function runtimeSpecifierError(
specifier: string,
target: PluginBuildTarget | "shared",
importer: string,
): OnResolveResult | null {
let kind: string | null = null;
if (specifier === "@getpaseo/plugin/client/host") kind = "host-private";
else if (
(specifier === "@getpaseo/plugin" ||
specifier.startsWith("@getpaseo/plugin/") ||
specifier === "@paseo/plugin" ||
specifier.startsWith("@paseo/plugin/")) &&
!(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier)
)
kind = "Unknown SDK";
else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node"))
kind = "Node";
else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only";
else if (
target !== "client" &&
(isPluginClientOnlySdkSpecifier(specifier) ||
/^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test(
specifier,
))
)
kind = "client-only";
return kind
? {
errors: [
{
text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`,
},
],
}
: null;
}
function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void {
function inputLocation(file: string): PluginModuleLocation | null {
const absolutePath = path.resolve(file);
// Metafile keys must stay unchanged for graph traversal. Only filesystem
// inputs have canonical paths; data URLs and external specifiers do not.
return directoryTarget(
existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath,
pluginDirectory,
);
}
const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared");
const visited = new Set<string>();
while (pending.length) {
const file = pending.pop()!;
if (visited.has(file)) continue;
visited.add(file);
for (const dependency of inputs[file]?.imports ?? []) {
const location = dependency.external ? null : inputLocation(dependency.path);
const error =
runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ??
(dependency.external
? null
: moduleBoundaryError(
location === "invalid" ? null : location,
"shared",
dependency.path,
));
if (error?.errors?.length) throw new Error(error.errors[0].text);
if (!dependency.external) pending.push(dependency.path);
} finally {
worker.off("message", onMessage!);
worker.off("error", onError!);
worker.off("exit", onExit!);
}
}
}
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
const { build } = loadEsbuild();
// Use native canonical paths throughout: TypeScript expands Windows short names
// when resolving type references, while the JS realpath implementation retains them.
const pluginDirectory = realpathSync.native(path.dirname(entryPath));
const result = await build({
entryPoints: [realpathSync.native(entryPath)],
bundle: true,
format: "cjs",
jsx: "automatic",
// `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon.
loader: { ".svg": "text" },
platform: target === "server" ? "node" : "neutral",
target: target === "server" ? "node20" : "es2020",
// The neutral platform reads no package.json entry fields, so packages without
// `exports` would not resolve. Read the same fields as esbuild's browser platform
// minus `browser`, since the bundle also runs in React Native.
mainFields: target === "client" ? ["module", "main"] : undefined,
// Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro,
// so apply the same compatibility transform before the app evaluates them from source.
supported: target === "client" ? { "async-await": false } : undefined,
external:
target === "client"
? [
...PLUGIN_SDK_SPECIFIERS,
"@tanstack/react-query",
"react",
"react/jsx-runtime",
"react-native",
"zod",
]
: SERVER_HOST_MODULES,
plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)],
metafile: true,
logLevel: "silent",
treeShaking: true,
write: false,
});
checkSharedDependencies(result.metafile.inputs, pluginDirectory);
const output = result.outputFiles[0]?.text;
if (!output) throw new Error(`Plugin ${target} compilation produced no output`);
return wrapCommonJsBundle(makeHermesInteropEager(output));
const compiler = new PluginCompiler();
export function runCompilerTask<T extends CompilerTask>(
task: T,
onLog?: (data: Record<string, unknown>, message: string) => void,
): Promise<TaskResult[T["kind"]]> {
return compiler.run(task, onLog);
}
export async function compilePlugin(entryPaths: {
client: string | null;
server: string | null;
}): Promise<{
clientBundle: string | null;
serverBundle: string | null;
}> {
const [clientBundle, serverBundle] = await Promise.all([
entryPaths.client ? compileTarget(entryPaths.client, "client") : null,
entryPaths.server ? compileTarget(entryPaths.server, "server") : null,
]);
return { clientBundle, serverBundle };
export function compilePlugin(entries: Parameters<typeof build.compilePlugin>[0]) {
return runCompilerTask({ kind: "entries", entries });
}
export function compileDirectoryPlugin(directory: string, version: string, builtinId?: string) {
return runCompilerTask({ kind: "directory", directory, version, builtinId });
}
@@ -526,7 +526,7 @@ describe("PluginService", () => {
if (!address || typeof address === "string") throw new Error("Missing registry address");
const url = `http://127.0.0.1:${address.port}`;
const store = createStore(home);
store.patch({ pluginsEnabled: false });
await store.patch({ pluginsEnabled: false });
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0", {
managedSources: new ManagedPluginSources(home, { defaultUrl: url }),
@@ -1079,13 +1079,13 @@ export default function contribute(plugin: unknown) {
await service.installDirectory({ path: first });
await service.installDirectory({ path: second });
store.patch({ pluginsEnabled: false });
await store.patch({ pluginsEnabled: false });
expect(service.catalog()).toEqual([]);
await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow(
"Plugin is not available",
);
store.patch({ pluginsEnabled: true });
await store.patch({ pluginsEnabled: true });
await service.reloadPlugin("first");
expect((await service.listPlugins()).map(({ id, status }) => ({ id, status }))).toEqual([
{ id: "first", status: "running" },
@@ -1100,16 +1100,16 @@ export default function contribute(plugin: unknown) {
const store = createStore(home, {
slow: { source: "directory", path: "/plugins/slow", enabled: true },
});
store.patch({ pluginsEnabled: false });
await store.patch({ pluginsEnabled: false });
const paused = createPausedRuntime();
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
runtime: paused.runtime,
});
await service.start();
store.patch({ pluginsEnabled: true });
await store.patch({ pluginsEnabled: true });
await paused.started;
store.patch({ pluginsEnabled: false });
await store.patch({ pluginsEnabled: false });
paused.releaseStart();
await service.stopAllPlugins();
@@ -1119,6 +1119,47 @@ export default function contribute(plugin: unknown) {
]);
});
it("persists overlapping enable and disable without losing either source mutation", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const store = createStore(home, {
a: { source: "directory", path: "/plugins/a", enabled: false },
b: { source: "directory", path: "/plugins/b", enabled: true },
});
const paused = createPluginSelectivePausedRuntime("unused");
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
runtime: paused.runtime,
});
await service.start();
await Promise.all([service.enablePlugin("a"), service.disablePlugin("b")]);
expect(store.get().plugins).toEqual({
a: { source: "directory", path: "/plugins/a", enabled: true },
b: { source: "directory", path: "/plugins/b", enabled: false },
});
await service.stopAllPlugins();
});
it("clears a failed disable intent so the previous enabled state can publish", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const store = createStore(home, {
slow: { source: "directory", path: "/plugins/slow", enabled: false },
});
const paused = createPausedRuntime();
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
runtime: paused.runtime,
});
await service.start();
const enabling = service.enablePlugin("slow");
await paused.started;
await writeFile(path.join(home, "config.json"), "invalid JSON");
await expect(service.disablePlugin("slow")).rejects.toThrow("Invalid JSON");
paused.releaseStart();
await expect(enabling).resolves.toMatchObject({ enabled: true, status: "running" });
expect(service.catalog()).toEqual([{ id: "slow", clientBundle: "bundle" }]);
await service.stopAllPlugins();
});
it("does not publish an in-flight enable after a later plugin disable", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
+60 -20
View File
@@ -97,6 +97,10 @@ export class PluginService {
private readonly providerListeners = new Set<() => void>();
private lifecycle = Promise.resolve();
private globalStartsBlocked = true;
private readonly pendingOperations = new Map<
string,
{ kind: "disable" | "remove"; completion: Promise<void> }
>();
private started = false;
private readonly settingsListeners = new Set<(pluginId: string, settingsId: string) => void>();
@@ -298,11 +302,7 @@ export class PluginService {
`Plugin ID "${pluginId}" is already configured; choose another ID with --id`,
);
}
const sources = {
...this.configStore.get().plugins,
[pluginId]: { source: "directory" as const, path: directory, enabled: true },
};
this.configStore.patch({ plugins: sources });
await this.patchSource(pluginId, { source: "directory", path: directory, enabled: true });
if (this.canPublish(pluginId)) await this.startConfigured(pluginId);
this.notify(pluginId);
const installed = await this.requireItem(pluginId);
@@ -361,15 +361,17 @@ export class PluginService {
await managedSources.discard(candidate);
throw error;
}
this.patchSource(pluginId, { source: "directory", path: candidate.directory, enabled: true });
await this.patchSource(pluginId, {
source: "directory",
path: candidate.directory,
enabled: true,
});
try {
if (this.canPublish(pluginId)) await this.startExplicit(pluginId, candidate.directory);
managedSources.commit(pluginId, candidate.record);
} catch (error) {
await this.stopPlugin(pluginId);
const sources = { ...this.configStore.get().plugins };
delete sources[pluginId];
this.configStore.patch({ plugins: sources });
await this.removeSource(pluginId);
await managedSources.discard(candidate);
this.errors.delete(pluginId);
this.notify(pluginId);
@@ -461,10 +463,11 @@ export class PluginService {
}
async enablePlugin(pluginId: string): Promise<PluginListItem> {
const source = this.requireSource(pluginId);
this.patchSource(pluginId, { ...source, enabled: true });
await this.patchSource(pluginId, (source) => ({ ...source, enabled: true }));
this.errors.delete(pluginId);
return this.enqueue(async () => {
// Cancellation persistence uses the store queue, so it never waits behind a blocked start.
await this.pendingOperations.get(pluginId)?.completion.catch(() => {});
const current = this.requireSource(pluginId);
if (current.enabled !== false && this.configStore.get().pluginsEnabled === true) {
await this.startExplicit(pluginId, current.path);
@@ -475,8 +478,17 @@ export class PluginService {
}
async disablePlugin(pluginId: string): Promise<PluginListItem> {
const source = this.requireSource(pluginId);
this.patchSource(pluginId, { ...source, enabled: false });
const operation = {
kind: "disable",
completion: this.patchSource(pluginId, (source) => ({ ...source, enabled: false })),
} as const;
this.pendingOperations.set(pluginId, operation);
try {
await operation.completion;
} finally {
if (this.pendingOperations.get(pluginId) === operation)
this.pendingOperations.delete(pluginId);
}
const stopping = this.stopPlugin(pluginId);
return this.enqueue(async () => {
await stopping;
@@ -488,10 +500,15 @@ export class PluginService {
async removePlugin(pluginId: string): Promise<void> {
this.requireSource(pluginId);
const operation = { kind: "remove", completion: this.removeSource(pluginId) } as const;
this.pendingOperations.set(pluginId, operation);
try {
await operation.completion;
} finally {
if (this.pendingOperations.get(pluginId) === operation)
this.pendingOperations.delete(pluginId);
}
const stopping = this.stopPlugin(pluginId);
const sources = { ...this.configStore.get().plugins };
delete sources[pluginId];
this.configStore.patch({ plugins: sources });
await this.enqueue(async () => {
await stopping;
this.runtime.clearLogs(pluginId);
@@ -564,6 +581,7 @@ export class PluginService {
private canPublish(pluginId: string): boolean {
const config = this.configStore.get();
return (
!this.pendingOperations.has(pluginId) &&
!this.globalStartsBlocked &&
config.pluginsEnabled === true &&
config.plugins?.[pluginId]?.enabled !== false &&
@@ -777,7 +795,10 @@ export class PluginService {
await managedSources.discard(candidate);
throw new Error(`Plugin is no longer configured: ${pluginId}`);
}
this.patchSource(pluginId, { ...activatedSource, path: candidate.directory });
await this.patchSource(pluginId, (currentSource) => ({
...currentSource,
path: candidate.directory,
}));
this.errors.delete(pluginId);
this.notify(pluginId);
let warning: string | undefined;
@@ -819,9 +840,28 @@ export class PluginService {
return source;
}
private patchSource(pluginId: string, source: PluginSource): void {
this.configStore.patch({
plugins: { ...this.configStore.get().plugins, [pluginId]: source },
private async patchSource(
pluginId: string,
update: PluginSource | ((source: PluginSource) => PluginSource),
): Promise<void> {
await this.configStore.patch((current) => {
const source = current.plugins?.[pluginId];
if (typeof update === "function" && !source)
throw new Error(`Plugin is not configured: ${pluginId}`);
return {
plugins: {
...current.plugins,
[pluginId]: typeof update === "function" ? update(source!) : update,
},
};
});
}
private async removeSource(pluginId: string): Promise<void> {
await this.configStore.patch((current) => {
const sources = { ...current.plugins };
delete sources[pluginId];
return { plugins: sources };
});
}
@@ -1,3 +1,9 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { performance } from "node:perf_hooks";
import pino from "pino";
import { createPaseoDaemon } from "../bootstrap.js";
import { loadConfig } from "../config.js";
import { createRequire } from "node:module";
import { runInNewContext } from "node:vm";
import { fileURLToPath } from "node:url";
@@ -198,6 +204,60 @@ export default function contribute(server: PluginServerContext) {
}
}, 60_000);
test("password-protected config reload preserves startup auth and session construction does not read config", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-protected-config-reload-"));
roots.push(home);
const password = "synthetic-audit-password";
const config = loadConfig(home, { env: { PASEO_PASSWORD: password, PASEO_RELAY_ENABLED: "0" } });
const daemon = await createPaseoDaemon(
{
...config,
listen: "127.0.0.1:0",
mcpEnabled: false,
agentClients: createTestAgentClients(),
staticDir: home,
},
pino({ level: "silent" }),
);
await daemon.start();
const target = daemon.getListenTarget();
if (target?.type !== "tcp") throw new Error("Expected TCP daemon");
const connect = () =>
new DaemonClient({ url: `ws://127.0.0.1:${target.port}/ws`, password, appVersion: "0.4.0" });
const client = connect();
const replacement = connect();
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const configPath = path.join(home, "config.json");
await writeFile(configPath, JSON.stringify({ daemon: { browserTools: { enabled: true } } }));
let longestGap = 0;
let previous = performance.now();
const probe = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - previous);
previous = now;
}, 1);
try {
const reloaded = await client.reloadDaemonConfig();
longestGap = Math.max(longestGap, performance.now() - previous);
expect(reloaded.appliedPaths).toContain("daemon.browserTools.enabled");
expect(longestGap).toBeLessThan(50);
} finally {
clearInterval(probe);
}
expect((await client.getDaemonConfig()).config.browserTools.enabled).toBe(true);
// A new physical session uses ready daemon state; corrupt config is reported only by an explicit read.
await writeFile(configPath, "invalid JSON");
await replacement.connect();
await replacement.fetchAgents({ subscribe: {} });
await expect(replacement.reloadDaemonConfig()).rejects.toThrow("Invalid JSON");
} finally {
await Promise.all([client.close(), replacement.close()]);
await daemon.stop();
}
}, 60_000);
test("daemon config reload enables and disables configured plugins without restarting", async () => {
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-reload-plugin-"));
const paseoHomeRoot = await mkdtemp(path.join(tmpdir(), "paseo-reload-home-"));
@@ -465,3 +525,24 @@ export default function contribute(server: PluginServerContext) {
await daemon.close();
}
}, 60_000);
test("keeps reload and enable responsive while compiling a real plugin", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-responsive-plugin-"));
roots.push(directory);
const { stdout, stderr } = await promisify(execFile)(
process.execPath,
[
"--import",
"tsx",
fileURLToPath(new URL("./test-fixtures/compile-responsiveness.e2e.ts", import.meta.url)),
directory,
],
{ timeout: 20_000 },
);
process.stderr.write(stderr);
const results = z
.array(z.object({ blockingMs: z.number() }))
.length(2)
.parse(JSON.parse(stdout));
for (const result of results) expect(result.blockingMs).toBeLessThan(20);
});
@@ -0,0 +1,76 @@
import { spawnProcess } from "../../utils/spawn.js";
import type { PluginManifest } from "./manifest.js";
const OUTPUT_LIMIT = 64 * 1024;
export async function runPluginBuild(
directory: string,
commands: PluginManifest["build"],
logger: { info(data: Record<string, unknown>, message: string): void },
environment: NodeJS.ProcessEnv,
): Promise<void> {
for (const command of commands ?? []) {
logger.info({ directory, command }, "Running plugin build command");
const result = await run(command, directory, environment);
if (result.stdout)
logger.info({ directory, command, output: result.stdout }, "Plugin build stdout");
if (result.stderr)
logger.info({ directory, command, output: result.stderr }, "Plugin build stderr");
if (result.error) {
throw new Error(
`Plugin build command failed to start: ${formatCommand(command)}\n${result.error.message}`,
);
}
if (result.exitCode !== 0) {
throw new Error(
`Plugin build command failed (exit ${result.exitCode}): ${formatCommand(command)}${formatOutput(result)}`,
);
}
}
}
function run(
command: string[],
directory: string,
environment: NodeJS.ProcessEnv,
): Promise<CommandResult> {
return new Promise((resolve) => {
const executable = command[0]!;
const arguments_ = command.slice(1);
const child = spawnProcess(executable, arguments_, { cwd: directory, env: environment });
let stdout = "";
let stderr = "";
let error: Error | undefined;
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout = appendOutput(stdout, chunk.toString());
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr = appendOutput(stderr, chunk.toString());
});
child.once("error", (spawnError) => {
error = spawnError;
});
child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr, error }));
});
}
function appendOutput(current: string, next: string): string {
return `${current}${next}`.slice(-OUTPUT_LIMIT);
}
function formatCommand(command: string[]): string {
return command.map((argument) => JSON.stringify(argument)).join(" ");
}
function formatOutput(result: CommandResult): string {
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
return output ? `\n${output}` : "";
}
interface CommandResult {
exitCode: number | null;
stdout: string;
stderr: string;
error?: Error;
}
@@ -1,6 +1,8 @@
import { chmod, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { Writable } from "node:stream";
import pino from "pino";
import { afterEach, describe, expect, it } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { runPluginBuild } from "./preparation.js";
@@ -117,6 +119,52 @@ describe("runPluginBuild", () => {
).rejects.toThrow(/Plugin build command failed \(exit 2\)/);
});
it("forwards build progress and output from the worker to the caller's logger", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory);
const records: Array<{ msg: string; output?: string }> = [];
const logger = pino(
new Writable({
write(chunk, _encoding, callback) {
records.push(JSON.parse(chunk.toString()));
callback();
},
}),
);
await runPluginBuild(
directory,
[[process.execPath, "-e", 'console.log("prepared"); console.error("diagnostic");']],
logger,
);
expect(records).toMatchObject([
{ msg: "Running plugin build command" },
{ msg: "Plugin build stdout", output: "prepared\n" },
{ msg: "Plugin build stderr", output: expect.stringContaining("diagnostic\n") },
]);
});
it("uses the caller's current environment across consecutive builds", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory);
const previous = process.env.PASEO_PLUGIN_TEST_BUILD_VALUE;
const command = [
process.execPath,
"-e",
'require("node:fs").writeFileSync(process.argv[1], process.env.PASEO_PLUGIN_TEST_BUILD_VALUE);',
];
try {
process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = "first";
await runPluginBuild(directory, [[...command, "first.txt"]], createTestLogger());
process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = "second";
await runPluginBuild(directory, [[...command, "second.txt"]], createTestLogger());
expect(await readFile(path.join(directory, "first.txt"), "utf8")).toBe("first");
expect(await readFile(path.join(directory, "second.txt"), "utf8")).toBe("second");
} finally {
if (previous === undefined) delete process.env.PASEO_PLUGIN_TEST_BUILD_VALUE;
else process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = previous;
}
});
it("handles empty or undefined build commands gracefully", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory);
@@ -1,71 +1,15 @@
import { spawnProcess } from "../../utils/spawn.js";
import type pino from "pino";
import type { PluginManifest } from "./manifest.js";
const OUTPUT_LIMIT = 64 * 1024;
import { runCompilerTask } from "./compiler.js";
export async function runPluginBuild(
directory: string,
commands: PluginManifest["build"],
logger: pino.Logger,
): Promise<void> {
for (const command of commands ?? []) {
logger.info({ directory, command }, "Running plugin build command");
const result = await run(command, directory);
if (result.stdout)
logger.info({ directory, command, output: result.stdout }, "Plugin build stdout");
if (result.stderr)
logger.info({ directory, command, output: result.stderr }, "Plugin build stderr");
if (result.error) {
throw new Error(
`Plugin build command failed to start: ${formatCommand(command)}\n${result.error.message}`,
);
}
if (result.exitCode !== 0) {
throw new Error(
`Plugin build command failed (exit ${result.exitCode}): ${formatCommand(command)}${formatOutput(result)}`,
);
}
}
}
function run(command: string[], directory: string): Promise<CommandResult> {
return new Promise((resolve) => {
const executable = command[0]!;
const arguments_ = command.slice(1);
const child = spawnProcess(executable, arguments_, { cwd: directory });
let stdout = "";
let stderr = "";
let error: Error | undefined;
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout = appendOutput(stdout, chunk.toString());
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr = appendOutput(stderr, chunk.toString());
});
child.once("error", (spawnError) => {
error = spawnError;
});
child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr, error }));
});
}
function appendOutput(current: string, next: string): string {
return `${current}${next}`.slice(-OUTPUT_LIMIT);
}
function formatCommand(command: string[]): string {
return command.map((argument) => JSON.stringify(argument)).join(" ");
}
function formatOutput(result: CommandResult): string {
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
return output ? `\n${output}` : "";
}
interface CommandResult {
exitCode: number | null;
stdout: string;
stderr: string;
error?: Error;
if (!commands?.length) return;
await runCompilerTask(
{ kind: "prepare", directory, commands, environment: { ...process.env } },
(data, message) => logger.info(data, message),
);
}
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { fork } from "node:child_process";
import { tmpdir } from "node:os";
import { PassThrough } from "node:stream";
@@ -1849,9 +1849,9 @@ export default function contribute(plugin: any) {
const child = createReloadChild("blocked", events);
const runtime = createTestRuntime({ spawnChild: () => child });
await expect(runtime.startPlugin("blocked", directory, () => false)).rejects.toThrow(
"Plugin start cancelled: blocked",
);
await expect(
runtime.startPlugin("blocked", directory, () => events.length === 0),
).rejects.toThrow("Plugin start cancelled: blocked");
expect(runtime.catalog()).toEqual([]);
expect(events).toEqual(["start:blocked", "shutdown:blocked", "exit:blocked"]);
@@ -2055,3 +2055,50 @@ export default function contribute(server) {
await runtime.stopAll();
}
});
const examplesDirectory = fileURLToPath(
new URL("../../../../../plugin-examples/", import.meta.url),
);
// The Markdown example owns npm preparation in the real app fixture:
// packages/app/e2e/browser/plugin-file-panel-examples.spec.ts.
const examples = (await readdir(examplesDirectory, { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && entry.name !== "markdown-panel")
.map((entry) => entry.name);
it.each(examples)("builds and loads the %s plugin example", async (example) => {
const settingsDirectory = await mkdtemp(path.join(tmpdir(), "paseo-example-settings-"));
temporaryDirectories.push(settingsDirectory);
const runtime = createTestRuntime({ settingsDirectory }, undefined, "0.13.2");
try {
await runtime.startPlugin(example, path.join(examplesDirectory, example));
expect(runtime.catalog()).toMatchObject([{ id: example }]);
expect(pluginMessages(runtime, example)).toContain("[paseo] Plugin ready");
} finally {
await runtime.stopAll();
}
});
it("does not initialize a plugin cancelled during compilation", async () => {
const directory = await createPlugin("cancelled-build", "export default () => {};");
const marker = path.join(directory, "initialized.txt");
await writeFile(
path.join(directory, "index.server.ts"),
`
import { writeFileSync } from "node:fs";
export default function contribute() {
writeFileSync(${JSON.stringify(marker)}, "initialized");
return () => {};
}`,
);
const runtime = createTestRuntime();
let allowed = true;
try {
const loading = runtime.startPlugin("cancelled-build", directory, () => allowed);
allowed = false;
await expect(loading).rejects.toThrow("Plugin start cancelled: cancelled-build");
expect(await readTextIfPresent(marker)).toBeNull();
expect(runtime.catalog()).toEqual([]);
} finally {
await runtime.stopAll();
}
});
+18 -54
View File
@@ -13,7 +13,6 @@ import type {
} from "@getpaseo/plugin/server";
import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js";
import { fork } from "node:child_process";
import { stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
@@ -28,10 +27,8 @@ import {
type ProviderInput,
} from "@getpaseo/plugin/server/provider";
import type { PluginLogEntry } from "@getpaseo/protocol/messages";
import { compilePlugin } from "./compiler.js";
import { readPluginManifest } from "./manifest.js";
import { compileDirectoryPlugin } from "./compiler.js";
import type { PluginRequirements } from "@getpaseo/protocol/messages";
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
import type {
PluginProcessMessage,
PluginProcessRequest,
@@ -43,8 +40,6 @@ import { PluginSessionSocket } from "./session-socket.js";
import { InternalPluginChild } from "./internal-child.js";
import { evaluateBundle } from "./bundle-evaluator.js";
const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const;
const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const;
const REQUEST_TIMEOUT_MS = 30_000;
const TOOL_CALL_TIMEOUT_MS = 10 * 60_000;
const MAX_LOG_ENTRIES = 500;
@@ -273,35 +268,6 @@ function send(child: PluginChild, message: PluginProcessRequest): Promise<void>
});
}
async function findEntry(directory: string, filenames: readonly string[]): Promise<string | null> {
for (const filename of filenames) {
const filePath = path.join(directory, filename);
const info = await stat(filePath).catch(() => null);
if (info?.isFile()) return filePath;
}
return null;
}
async function resolveEntryPaths(directory: string): Promise<{
client: string | null;
server: string | null;
}> {
const [client, server] = await Promise.all([
findEntry(directory, CLIENT_ENTRY_FILENAMES),
findEntry(directory, SERVER_ENTRY_FILENAMES),
]);
if (client || server) return { client, server };
const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]);
if (legacyEntry) {
throw new Error(
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration",
);
}
throw new Error(
`Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`,
);
}
export class PluginRuntime {
private readonly plugins = new Map<string, LoadedPlugin>();
private readonly pendingEvents = new Set<Promise<void>>();
@@ -350,10 +316,16 @@ export class PluginRuntime {
): Promise<void> {
if (this.plugins.has(pluginId)) throw new Error(`Plugin is already running: ${pluginId}`);
this.appendLog(pluginId, "stdout", "[paseo] Loading plugin");
const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath).catch((error) => {
this.appendLog(pluginId, "stderr", `[paseo] Plugin failed to load: ${describeError(error)}`);
throw error;
});
const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath, canPublish).catch(
(error) => {
this.appendLog(
pluginId,
"stderr",
`[paseo] Plugin failed to load: ${describeError(error)}`,
);
throw error;
},
);
if (!canPublish()) {
await this.stopPlugin(loaded);
throw new Error(`Plugin start cancelled: ${pluginId}`);
@@ -367,12 +339,8 @@ export class PluginRuntime {
if (this.plugins.has(input.id)) throw new Error(`Plugin is already running: ${input.id}`);
this.appendLog(input.id, "stdout", "[paseo] Loading plugin");
const directory = path.resolve(input.directory);
const manifest = await readPluginManifest(directory);
if (manifest.id !== input.id) {
throw new Error(`Built-in plugin ${input.id} has manifest ID ${manifest.id}`);
}
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
const bundles = await compilePlugin(await resolveEntryPaths(directory));
const bundles = await compileDirectoryPlugin(directory, this.daemonVersion, input.id);
const { manifest } = bundles;
if (!bundles.serverBundle) throw new Error(`Built-in plugin ${input.id} needs a server entry`);
const loaded = await this.launchPlugin({
pluginId: input.id,
@@ -388,11 +356,7 @@ export class PluginRuntime {
}
async validatePlugin(configuredPath: string): Promise<void> {
const directory = path.resolve(configuredPath);
const manifest = await readPluginManifest(directory);
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
const entryPaths = await resolveEntryPaths(directory);
await compilePlugin(entryPaths);
await compileDirectoryPlugin(path.resolve(configuredPath), this.daemonVersion);
}
async stopPluginById(pluginId: string): Promise<boolean> {
@@ -738,12 +702,12 @@ export class PluginRuntime {
private async loadDirectoryPlugin(
pluginId: string,
configuredPath: string,
canPublish: () => boolean,
): Promise<LoadedPlugin> {
const directory = path.resolve(configuredPath);
const manifest = await readPluginManifest(directory);
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
const entryPaths = await resolveEntryPaths(directory);
const bundles = await compilePlugin(entryPaths);
const bundles = await compileDirectoryPlugin(directory, this.daemonVersion);
const { manifest } = bundles;
if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`);
const serverBundle = bundles.serverBundle;
if (!serverBundle) {
return {
@@ -0,0 +1,187 @@
import assert from "node:assert/strict";
import { createHook, executionAsyncResource } from "node:async_hooks";
import { open as openFile, writeFile } from "node:fs/promises";
import { performance, PerformanceObserver } from "node:perf_hooks";
import path from "node:path";
import { MessagePort } from "node:worker_threads";
import pino from "pino";
import { DaemonConfigStore } from "../../daemon-config-store.js";
import { PluginService } from "../index.js";
// A fresh process excludes the E2E harness's transform/bootstrap garbage from
// the compiler measurement. All work and dependencies below are real.
const directory = process.argv[2];
assert.ok(directory, "Fixture directory is required");
const results: { blockingMs: number }[] = [];
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "responsive-build" }),
);
// Erased types stress TypeScript's AST walk without a multi-MiB runtime bundle.
// Payload transfer is a different cost from import validation.
const entryFile = await openFile(path.join(directory, "index.client.ts"), "w");
try {
await entryFile.write("type FixtureValues = [");
for (let offset = 0; offset < 350_000; offset += 10_000) {
await entryFile.write(
Array.from({ length: 10_000 }, (_, index) => offset + index).join(",") + ",",
);
}
await entryFile.write(
']; export default function contribute() { return () => "compiled fixture"; }',
);
} finally {
await entryFile.close();
}
const store = new DaemonConfigStore(directory, {
mcp: { injectIntoAgents: true },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
pluginsEnabled: true,
plugins: {
"responsive-build": { source: "directory", path: directory, enabled: false },
},
});
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0");
await service.start();
try {
for (const action of [
() => service.enablePlugin("responsive-build"),
() => service.reloadPlugin("responsive-build"),
]) {
// Node 22.19+ exposes per-thread CPU accounting; the repository's older
// Node declarations do not include it yet. .tool-versions pins Node 22.20.
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
process,
"threadCpuUsage",
);
const startedAt = performance.now();
let previous = startedAt;
let cpu = threadCpuUsage();
let longestWall = 0;
let longestBlocking = 0;
let longestWallCpu = 0;
let workerStarts = 0;
let portCallbacks = 0;
let longestWallEvents = { workerStarts: 0, portCallbacks: 0 };
let longestBlockingWindow = { wallMs: 0, cpuMs: 0, resource: "" };
let probe: ReturnType<typeof setInterval>;
const callbacks = new Map<number, { wall: number; cpu: NodeJS.CpuUsage; resource: string }>();
const recordBlocking = (
blockingMs: number,
window: { wallMs: number; cpuMs: number; resource: string },
) => {
if (blockingMs > longestBlocking) {
longestBlocking = blockingMs;
longestBlockingWindow = window;
}
};
let longestGc = 0;
const observeGc = (entries: readonly import("node:perf_hooks").PerformanceEntry[]) => {
for (const entry of entries) {
if (entry.startTime >= startedAt) longestGc = Math.max(longestGc, entry.duration);
}
};
const gc = new PerformanceObserver((list) => observeGc(list.getEntries()));
gc.observe({ entryTypes: ["gc"] });
// Measure work within callbacks, not CPU-counter increments attributed to
// the idle interval between them. Exclude this probe's own timer callback.
const activity = createHook({
init(_id, type) {
if (type === "WORKER") workerStarts++;
},
before(id) {
const resource = executionAsyncResource();
if (resource instanceof MessagePort) portCallbacks++;
if (resource === probe) return;
callbacks.set(id, {
wall: performance.now(),
cpu: threadCpuUsage(),
resource: Object.prototype.toString.call(resource),
});
},
after(id) {
const start = callbacks.get(id);
if (!start) return;
callbacks.delete(id);
const wallMs = performance.now() - start.wall;
const { user, system } = threadCpuUsage(start.cpu);
const cpuMs = (user + system) / 1_000;
// A callback prevents the loop from serving IO for its entire span.
// Use the high-resolution clock, not Windows' coarse CPU increments.
recordBlocking(wallMs, { wallMs, cpuMs, resource: start.resource });
},
}).enable();
const sample = () => {
const now = performance.now();
const currentCpu = threadCpuUsage();
const wallMs = now - previous;
const cpuMs = (currentCpu.user - cpu.user + currentCpu.system - cpu.system) / 1_000;
// Native worker construction and message deserialization can precede
// async_hooks.before. Include their interval when those events occurred.
if (workerStarts || portCallbacks) {
recordBlocking(Math.min(wallMs, cpuMs), {
wallMs,
cpuMs,
resource: "worker/port interval",
});
}
if (wallMs > longestWall) {
longestWall = wallMs;
longestWallCpu = cpuMs;
longestWallEvents = { workerStarts, portCallbacks };
}
workerStarts = 0;
portCallbacks = 0;
previous = now;
cpu = currentCpu;
};
// Sleep between probes: a continuously runnable setImmediate loop consumes
// a core while the worker compiles. Raw gaps remain diagnostics; the
// assertion measures elapsed daemon-thread callback spans and GC pauses.
probe = setInterval(sample, 1);
try {
// The caller's current callback began before the hook was enabled.
// Include synchronous work before the action's first await explicitly.
const actionStartedAt = performance.now();
const actionCpu = threadCpuUsage();
const pending = action();
const actionWallMs = performance.now() - actionStartedAt;
const actionCost = threadCpuUsage(actionCpu);
recordBlocking(actionWallMs, {
wallMs: actionWallMs,
cpuMs: (actionCost.user + actionCost.system) / 1_000,
resource: "action()",
});
await pending;
await new Promise<void>((resolve) => setImmediate(resolve));
sample();
observeGc(gc.takeRecords());
} finally {
clearInterval(probe);
activity.disable();
gc.disconnect();
}
longestBlocking = Math.max(longestBlocking, longestGc);
process.stderr.write(
`plugin compile main-thread blocking: ${longestBlocking.toFixed(2)} ms ${JSON.stringify({ longestWall, longestWallCpu, ...longestWallEvents, longestBlockingWindow, longestGc })}\n`,
);
assert.ok(service.catalog()[0]?.clientBundle.includes("compiled fixture"));
results.push({ blockingMs: longestBlocking });
}
// Evaluate after both probes so parsing the returned code is not charged to compilation.
const factory = globalThis.eval(service.catalog()[0]!.clientBundle) as (
require: (specifier: string) => never,
) => { default: () => () => string };
const result = factory((specifier) => {
throw new Error(`Unexpected fixture dependency: ${specifier}`);
}).default()();
assert.equal(result, "compiled fixture");
} finally {
await service.stopAllPlugins();
}
process.stdout.write(JSON.stringify(results));
@@ -1,3 +1,4 @@
import { chmod, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises";
import { chmodSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import path from "node:path";
@@ -52,3 +53,30 @@ function resolveSymlinkedFile(filePath: string): string {
throw error;
}
}
export async function ensurePrivateFileAsync(filePath: string): Promise<void> {
if (process.platform === "win32") return;
await chmod(filePath, PRIVATE_FILE_MODE).catch(() => {});
}
export async function writePrivateFileAtomic(filePath: string, data: string): Promise<void> {
const directory = path.dirname(filePath);
await mkdir(directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
if (process.platform !== "win32") await chmod(directory, PRIVATE_DIRECTORY_MODE).catch(() => {});
const destination = await realpath(filePath).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return filePath;
throw error;
});
const temporary = path.join(
path.dirname(destination),
`.${path.basename(destination)}.${process.pid}.${randomUUID()}`,
);
try {
await writeFile(temporary, data, { mode: PRIVATE_FILE_MODE });
await rename(temporary, destination);
await ensurePrivateFileAsync(destination);
} catch (error) {
await rm(temporary, { force: true });
throw error;
}
}
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { tmpdir } from "node:os";
@@ -7,6 +7,7 @@ import { ScriptRouteStore } from "./script-proxy.js";
import {
buildWorkspaceScriptPayloads,
createScriptStatusEmitter,
readPaseoConfigForProjection,
} from "./script-status-projection.js";
import { WorkspaceScriptPayloadSchema } from "@getpaseo/protocol/messages";
import type { ScriptHealthState } from "./script-health-monitor.js";
@@ -50,7 +51,7 @@ function createWorkspaceRepo(options?: {
};
}
function buildPayloads(input: {
async function buildPayloads(input: {
workspaceId: string;
workspaceDirectory: string;
paseoConfig?: PaseoConfig | null;
@@ -63,7 +64,9 @@ function buildPayloads(input: {
resolveHealth?: (hostname: string) => ScriptHealthState | null;
}) {
const paseoConfig =
input.paseoConfig !== undefined ? input.paseoConfig : loadConfig(input.workspaceDirectory);
input.paseoConfig !== undefined
? input.paseoConfig
: await loadConfig(input.workspaceDirectory);
const { routeStore, serviceProxy, ...rest } = input;
return buildWorkspaceScriptPayloads({
...rest,
@@ -72,8 +75,8 @@ function buildPayloads(input: {
});
}
function loadConfig(repoRoot: string): PaseoConfig | null {
const result = readPaseoConfig(repoRoot);
async function loadConfig(repoRoot: string): Promise<PaseoConfig | null> {
const result = await readPaseoConfig(repoRoot);
return result.ok ? result.config : null;
}
@@ -93,7 +96,7 @@ describe("script-status-projection", () => {
).toBeNull();
});
it("projects plain scripts and services differently", () => {
it("projects plain scripts and services differently", async () => {
const workspaceId = "workspace-plain-and-service";
const workspace = createWorkspaceRepo({
paseoConfig: {
@@ -116,7 +119,7 @@ describe("script-status-projection", () => {
try {
expect(
buildPayloads({
await buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
@@ -154,7 +157,7 @@ describe("script-status-projection", () => {
}
});
it("builds service hostnames from service-provided git metadata", () => {
it("builds service hostnames from service-provided git metadata", async () => {
const workspaceId = "workspace-service-metadata";
const workspace = createWorkspaceRepo({
branchName: "local-branch-that-should-not-be-read",
@@ -168,7 +171,7 @@ describe("script-status-projection", () => {
const runtimeStore = new WorkspaceScriptRuntimeStore();
try {
const payloads = buildPayloads({
const payloads = await buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
serviceProxy: routeStore,
@@ -200,7 +203,7 @@ describe("script-status-projection", () => {
}
});
it("projects local and public service URLs while keeping proxyUrl public-first", () => {
it("projects local and public service URLs while keeping proxyUrl public-first", async () => {
const workspaceId = "workspace-public-service";
const workspace = createWorkspaceRepo({
paseoConfig: {
@@ -214,7 +217,7 @@ describe("script-status-projection", () => {
try {
expect(
buildPayloads({
await buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
@@ -243,7 +246,7 @@ describe("script-status-projection", () => {
}
});
it("overlays runtime, route, and health state for running services", () => {
it("overlays runtime, route, and health state for running services", async () => {
const workspaceId = "workspace-running-service";
const workspace = createWorkspaceRepo({
branchName: "feature/card",
@@ -273,7 +276,7 @@ describe("script-status-projection", () => {
try {
expect(
buildPayloads({
await buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
@@ -301,7 +304,7 @@ describe("script-status-projection", () => {
}
});
it("maps internal pending health to null on the wire", () => {
it("maps internal pending health to null on the wire", async () => {
const workspaceId = "workspace-pending-health";
const workspace = createWorkspaceRepo({
paseoConfig: {
@@ -330,7 +333,7 @@ describe("script-status-projection", () => {
try {
expect(
buildPayloads({
await buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
@@ -358,7 +361,7 @@ describe("script-status-projection", () => {
}
});
it("includes orphaned running runtime entries even after config removal", () => {
it("includes orphaned running runtime entries even after config removal", async () => {
const workspaceId = "workspace-orphaned-service";
const workspace = createWorkspaceRepo();
const routeStore = new ScriptRouteStore();
@@ -381,7 +384,7 @@ describe("script-status-projection", () => {
try {
expect(
buildPayloads({
await buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
@@ -408,7 +411,7 @@ describe("script-status-projection", () => {
}
});
it("projects orphaned plain scripts as scripts instead of services", () => {
it("projects orphaned plain scripts as scripts instead of services", async () => {
const workspaceId = "workspace-orphaned-script";
const workspace = createWorkspaceRepo();
const routeStore = new ScriptRouteStore();
@@ -424,7 +427,7 @@ describe("script-status-projection", () => {
try {
expect(
buildPayloads({
await buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
@@ -449,7 +452,7 @@ describe("script-status-projection", () => {
}
});
it("readPaseoConfig fails with configPath and error when paseo.json is malformed", () => {
it("readPaseoConfig fails with configPath and error when paseo.json is malformed", async () => {
const workspace = createWorkspaceRepo();
const configPath = path.join(workspace.repoDir, "paseo.json");
writeFileSync(
@@ -458,7 +461,7 @@ describe("script-status-projection", () => {
);
try {
const result = readPaseoConfig(workspace.repoDir);
const result = await readPaseoConfig(workspace.repoDir);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("unreachable");
expect(result.configPath).toBe(configPath);
@@ -468,7 +471,7 @@ describe("script-status-projection", () => {
}
});
it("buildWorkspaceScriptPayloads given paseoConfig=null still surfaces orphaned runtime scripts", () => {
it("buildWorkspaceScriptPayloads given paseoConfig=null still surfaces orphaned runtime scripts", async () => {
const workspaceId = "workspace-null-config";
const workspace = createWorkspaceRepo();
const routeStore = new ScriptRouteStore();
@@ -484,7 +487,7 @@ describe("script-status-projection", () => {
try {
expect(
buildPayloads({
await buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
paseoConfig: null,
@@ -538,7 +541,12 @@ describe("script-status-projection", () => {
exitCode: null,
});
const session = { emit: vi.fn() };
const messages: unknown[] = [];
const session = {
emit: (message: unknown) => {
messages.push(message);
},
};
const emitUpdate = createScriptStatusEmitter({
sessions: () => [session],
serviceProxy: routeStore,
@@ -550,7 +558,11 @@ describe("script-status-projection", () => {
});
try {
emitUpdate(workspaceId, [
const originalConfig = await readPaseoConfigForProjection(
workspace.repoDir,
createTestLogger(),
);
await emitUpdate(workspaceId, [
{
scriptName: "api",
hostname: "api--repo.localhost",
@@ -558,9 +570,25 @@ describe("script-status-projection", () => {
health: "healthy",
},
]);
await Promise.resolve();
expect(session.emit).toHaveBeenCalledWith({
expect(await readPaseoConfigForProjection(workspace.repoDir, createTestLogger())).toBe(
originalConfig,
);
await emitUpdate(workspaceId, [
{ scriptName: "api", hostname: "api--repo.localhost", port: 3001, health: "unhealthy" },
]);
expect(messages.at(-1)).toMatchObject({
payload: {
scripts: [
{ scriptName: "api", health: "unhealthy" },
{ scriptName: "typecheck", health: null },
],
},
});
expect(await readPaseoConfigForProjection(workspace.repoDir, createTestLogger())).toBe(
originalConfig,
);
expect(messages).toContainEqual({
type: "script_status_update",
payload: {
workspaceId,
@@ -33,11 +33,11 @@ interface BuildWorkspaceScriptPayloadsOptions {
resolveHealth?: (hostname: string) => ScriptHealthState | null;
}
export function readPaseoConfigForProjection(
export async function readPaseoConfigForProjection(
workspaceDirectory: string,
logger: Logger,
): PaseoConfig | null {
const result = readPaseoConfig(workspaceDirectory);
): Promise<PaseoConfig | null> {
const result = await readPaseoConfig(workspaceDirectory);
if (result.ok) {
return result.config;
}
@@ -288,9 +288,9 @@ export function createScriptStatusEmitter({
serviceProxyPublicBaseUrl?: string | null;
resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise<string | null>;
logger: Logger;
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void {
return (workspaceId, scripts) => {
void (async () => {
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => Promise<void> {
return async (workspaceId, scripts) => {
try {
const workspaceDirectory = await resolveWorkspaceDirectory(workspaceId);
if (!workspaceDirectory) {
return;
@@ -304,7 +304,7 @@ export function createScriptStatusEmitter({
const projected = buildWorkspaceScriptPayloads({
workspaceId,
workspaceDirectory,
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
paseoConfig: await readPaseoConfigForProjection(workspaceDirectory, logger),
serviceProxy,
runtimeStore,
daemonPort: resolvedDaemonPort,
@@ -320,6 +320,8 @@ export function createScriptStatusEmitter({
for (const session of sessions()) {
session.emit(message);
}
})();
} catch (error) {
logger.warn({ err: error, workspaceId }, "Failed to project workspace script status");
}
};
}
@@ -425,6 +425,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
github: asGitHubService(github),
workspaceGitService: asWorkspaceGitService(workspaceGitService),
daemonConfigStore: asDaemonConfigStore({
readServicePorts: async () => undefined,
get: vi.fn(() => ({
mcp: { injectIntoAgents: false },
providers: {},
+14 -17
View File
@@ -81,7 +81,6 @@ import {
type WorkspaceScriptsService,
} from "./session/workspace-scripts/workspace-scripts-service.js";
import type { DaemonConfigStore } from "./daemon-config-store.js";
import { loadPersistedConfig } from "./persisted-config.js";
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
@@ -1232,7 +1231,7 @@ export class Session {
spawnWorkspaceScript,
assertAutomationAllowed: (workspaceId) =>
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
globalServicePorts: loadPersistedConfig(this.paseoHome).worktrees?.servicePorts,
getGlobalServicePorts: () => this.daemonConfigStore.readServicePorts(),
});
this.workspaceDirectory = new WorkspaceDirectory({
logger: this.sessionLogger,
@@ -2927,8 +2926,7 @@ export class Session {
case "daemon.get_pairing_offer.request":
return this.daemonSession.handleGetPairingOfferRequest(msg);
case "daemon.config.reload.request":
this.daemonSession.handleConfigReloadRequest(msg);
return undefined;
return this.daemonSession.handleConfigReloadRequest(msg);
case "hub.management.daemon.connect.request":
case "hub.management.daemon.get_status.request":
case "hub.management.daemon.disconnect.request":
@@ -2939,14 +2937,15 @@ export class Session {
case "daemon.update.request":
return this.daemonSession.handleUpdateRequest(msg);
case "set_daemon_config_request":
this.emit({
type: "set_daemon_config_response",
payload: {
requestId: msg.requestId,
config: this.daemonConfigStore.patch(msg.config),
},
return this.daemonConfigStore.patch(msg.config).then((config) => {
return this.emit({
type: "set_daemon_config_response",
payload: {
requestId: msg.requestId,
config,
},
});
});
return undefined;
case "read_project_config_request":
return this.projectConfigSession.handleReadProjectConfigRequest(msg);
case "write_project_config_request":
@@ -2964,7 +2963,7 @@ export class Session {
case "checkout.commits.list.request":
return this.checkoutSession.handleCommitsListRequest(msg);
case "checkout.commits.file_diff.request":
return this.checkoutSession.handleCommitFileDiffRequest(msg);
return this.checkoutSession.handleCommitFileDiffRequest(msg, this.delivery.requestSignal);
case "validate_branch_request":
return this.checkoutSession.handleValidateBranchRequest(msg);
case "branch_suggestions_request":
@@ -5670,7 +5669,7 @@ export class Session {
activityAt: null,
createdAt: workspace.createdAt,
diffStat,
scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord),
scripts: await this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord),
...(resolvedProjectRecord
? {
project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord),
@@ -7263,7 +7262,7 @@ export class Session {
private buildWorkspaceScriptPayloadSnapshot(
workspace: PersistedWorkspaceRecord,
project: PersistedProjectRecord | null,
): WorkspaceDescriptorPayload["scripts"] {
): Promise<WorkspaceDescriptorPayload["scripts"]> {
return this.workspaceScripts.buildSnapshot(workspace, project);
}
@@ -7787,8 +7786,6 @@ export class Session {
): Promise<void> {
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
const projection = "projected" as const;
const requestedLimit = msg.limit;
const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200);
const cursor: AgentTimelineCursor | undefined = msg.cursor
? {
epoch: msg.cursor.epoch,
@@ -7807,7 +7804,7 @@ export class Session {
const fetchedControlTimeline = this.agentManager.fetchTimeline(msg.agentId, {
direction,
cursor,
limit: pageLimit,
limit: msg.limit,
});
const selectedTimeline = {
timeline: fetchedControlTimeline,
@@ -9228,8 +9228,8 @@ test("workspace auto-name uses the backing root for a nested worktree", async ()
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "placeholder-branch"], { cwd: repoDir, stdio: "pipe" });
writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" });
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, {
await writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" });
await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, {
placeholderBranchName: "placeholder-branch",
});
const workspaceCwd = path.join(repoDir, "packages", "app");
@@ -9288,6 +9288,7 @@ test("workspace auto-name uses the backing root for a nested worktree", async ()
firstAgentContext: { prompt: "Fix checkout title" },
});
await vi.runAllTimersAsync();
await vi.waitFor(() => expect(emittedCwds).toEqual([workspaceCwd]));
expect(generateCalls).toBe(1);
expect(stored.get(workspace.workspaceId)).toMatchObject({
@@ -9299,7 +9300,7 @@ test("workspace auto-name uses the backing root for a nested worktree", async ()
.toString()
.trim(),
).toBe("placeholder-branch");
expect(readPaseoWorktreeMetadata(repoDir)).toMatchObject({
expect(await readPaseoWorktreeMetadata(repoDir)).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "attempted",
@@ -290,7 +290,10 @@ export class CheckoutSession {
}
}
async handleCommitFileDiffRequest(msg: CheckoutCommitFileDiffRequest): Promise<void> {
async handleCommitFileDiffRequest(
msg: CheckoutCommitFileDiffRequest,
signal: AbortSignal,
): Promise<void> {
const { cwd, sha, path, requestId } = msg;
try {
@@ -298,7 +301,7 @@ export class CheckoutSession {
if (path.length === 0 || isAbsolute(path) || path.split(/[\\/]/).includes("..")) {
throw new Error(`Invalid path: ${path}`);
}
const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path });
const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path, signal });
this.host.emit({
type: "checkout.commits.file_diff.response",
payload: { cwd, sha, path, file, error: null, requestId },
@@ -43,7 +43,7 @@ function makeSubsystem(overrides: {
listProviderAvailability?: () => Promise<ProviderAvailability[]>;
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
hubRelationships?: HubRelationshipManagement;
reloadConfig?: () => DaemonConfigReloadResult;
reloadConfig?: () => Promise<DaemonConfigReloadResult>;
}) {
const emitted: SessionOutboundMessage[] = [];
const restartIntents: Parameters<DaemonSessionHost["emitLifecycleIntent"]>[0][] = [];
@@ -67,7 +67,7 @@ function makeSubsystem(overrides: {
hubRelationships: overrides.hubRelationships,
reloadConfig:
overrides.reloadConfig ??
(() => ({
(async () => ({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: [],
@@ -78,16 +78,16 @@ function makeSubsystem(overrides: {
}
describe("DaemonSession", () => {
test("config reload returns the daemon-owned classification", () => {
test("config reload returns the daemon-owned classification", async () => {
const { subsystem, emitted } = makeSubsystem({
reloadConfig: () => ({
reloadConfig: async () => ({
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: ["daemon.listen"],
overrideControlledPaths: ["app.baseUrl"],
}),
});
subsystem.handleConfigReloadRequest({
await subsystem.handleConfigReloadRequest({
type: "daemon.config.reload.request",
requestId: "reload-1",
});
@@ -105,14 +105,14 @@ describe("DaemonSession", () => {
]);
});
test("config reload failures return a correlated RPC error", () => {
test("config reload failures return a correlated RPC error", async () => {
const { subsystem, emitted } = makeSubsystem({
reloadConfig: () => {
reloadConfig: async () => {
throw new Error("Invalid config");
},
});
subsystem.handleConfigReloadRequest({
await subsystem.handleConfigReloadRequest({
type: "daemon.config.reload.request",
requestId: "reload-2",
});
@@ -53,7 +53,7 @@ export interface DaemonSessionOptions {
getObservationMetrics?: () => Record<string, number>;
logger: pino.Logger;
hubRelationships?: HubRelationshipManagement;
reloadConfig: () => DaemonConfigReloadResult;
reloadConfig: () => Promise<DaemonConfigReloadResult>;
}
/**
@@ -79,7 +79,7 @@ export class DaemonSession {
private readonly logger: pino.Logger;
private readonly selfUpdate: DaemonSelfUpdateSessionController;
private readonly hubRelationships: HubRelationshipManagement | null;
private readonly reloadConfig: () => DaemonConfigReloadResult;
private readonly reloadConfig: () => Promise<DaemonConfigReloadResult>;
constructor(options: DaemonSessionOptions) {
this.host = options.host;
@@ -252,13 +252,13 @@ export class DaemonSession {
}
}
handleConfigReloadRequest(
async handleConfigReloadRequest(
msg: Extract<SessionInboundMessage, { type: "daemon.config.reload.request" }>,
): void {
): Promise<void> {
try {
this.host.emit({
type: "daemon.config.reload.response",
payload: { requestId: msg.requestId, ...this.reloadConfig() },
payload: { requestId: msg.requestId, ...(await this.reloadConfig()) },
});
} catch (error) {
this.logger.error({ err: error }, "Failed to reload daemon config");
@@ -228,7 +228,7 @@ describe("ProjectConfigSession", () => {
requestId: "write-uncommitted-setup",
repoRoot,
config: { worktree: { setup: "npm install" } },
expectedRevision: statPaseoConfigPath(repoRoot),
expectedRevision: await statPaseoConfigPath(repoRoot),
});
expect(emitted).toEqual([
@@ -1,10 +1,10 @@
import { realpathSync } from "node:fs";
import { resolve, sep } from "path";
import { resolve } from "path";
import type pino from "pino";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import type { ProjectRegistry } from "../../workspace-registry.js";
import {
readPaseoConfigForEdit,
resolveProjectConfigRoot,
writePaseoConfigForEdit,
type ProjectConfigRpcError,
} from "../../../utils/paseo-config-file.js";
@@ -47,7 +47,7 @@ export class ProjectConfigSession {
return;
}
const result = readPaseoConfigForEdit(repoRoot);
const result = await readPaseoConfigForEdit(repoRoot);
if (!result.ok) {
this.logger.warn(
{ repoRoot, requestId: msg.requestId, outcome: result.error.code },
@@ -94,7 +94,7 @@ export class ProjectConfigSession {
{ repoRoot, requestId: msg.requestId, outcome: "write_attempt" },
"Writing project config",
);
const result = writePaseoConfigForEdit({
const result = await writePaseoConfigForEdit({
repoRoot,
config: msg.config,
expectedRevision: msg.expectedRevision,
@@ -173,34 +173,21 @@ export class ProjectConfigSession {
}
private async resolveKnownProjectRoot(repoRoot: string): Promise<string | null> {
const requestedRoot = canonicalizeConfigRoot(repoRoot);
const requestedRoot = resolve(repoRoot);
const projects = await this.projectRegistry.list();
for (const project of projects) {
if (project.archivedAt !== null) {
continue;
}
const projectRoot = canonicalizeConfigRoot(project.rootPath);
if (requestedRoot === projectRoot) {
return projectRoot;
}
}
return null;
const activeRoots = new Set(
projects
.filter((project) => project.archivedAt === null)
.map((project) => resolve(project.rootPath)),
);
const root = await resolveProjectConfigRoot(requestedRoot, activeRoots);
if (!root) return null;
// Authorization follows live registry state after alias IO, including archive/removal.
const current = await this.projectRegistry.list();
return current.some(
(project) => project.archivedAt === null && resolve(project.rootPath) === root,
)
? root
: null;
}
}
function canonicalizeConfigRoot(repoRoot: string): string {
const resolved = resolve(repoRoot);
try {
return stripTrailingPathSeparators(realpathSync(resolved));
} catch {
return stripTrailingPathSeparators(resolved);
}
}
function stripTrailingPathSeparators(path: string): string {
let normalized = path;
while (normalized.length > 1 && normalized.endsWith(sep)) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
@@ -347,7 +347,7 @@ async function createBaseRecoveryFixture(baseBranch: string | null) {
test("preserves ordinary checkout behavior when no separate base was recorded", async () => {
const fixture = await createBaseRecoveryFixture(null);
writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" });
await writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" });
await fixture.archiveAndRemove();
await fixture.service.restore(fixture.workspace.workspaceId);
expect((await fixture.registry.get(fixture.workspace.workspaceId))?.baseBranch).toBeNull();
@@ -430,7 +430,7 @@ test("restores the branch HEAD with a base name when the exact base is missing",
});
expect(existsSync(fixture.workspace.cwd)).toBe(true);
expect((await fixture.registry.get(fixture.workspace.workspaceId))?.archivedAt).toBeNull();
const metadata = readPaseoWorktreeMetadata(fixture.workspace.cwd);
const metadata = await readPaseoWorktreeMetadata(fixture.workspace.cwd);
expect(metadata).toMatchObject({ baseRefName: "main" });
expect(metadata?.baseRef).toBeUndefined();
expect(
@@ -450,7 +450,7 @@ test("restore fetches a deleted local branch from origin and retains its base",
await fixture.archiveAndRemove();
execFileSync("git", ["branch", "-D", "feature"], { cwd: fixture.repoDir });
await fixture.service.restore(fixture.workspace.workspaceId);
expect(readPaseoWorktreeMetadata(fixture.workspace.cwd)?.baseRef).toBe("refs/heads/main");
expect((await readPaseoWorktreeMetadata(fixture.workspace.cwd))?.baseRef).toBe("refs/heads/main");
expect(
await getCheckoutStatus(fixture.workspace.cwd, { paseoHome: fixture.paseoHome }),
).toMatchObject({
@@ -1,3 +1,5 @@
import { DaemonConfigStore } from "../../daemon-config-store.js";
import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -82,6 +84,7 @@ interface BuildOptions {
spawnThrows?: string;
runtimeStore?: Pick<WorkspaceRuntimeStore, "get">;
automationError?: Error;
getGlobalServicePorts?: () => Promise<PaseoServicePortAllocation | undefined>;
}
function buildService(options: BuildOptions = {}) {
@@ -111,6 +114,7 @@ function buildService(options: BuildOptions = {}) {
getDaemonTcpHost: () => "127.0.0.1",
serviceProxyPublicBaseUrl: null,
resolveScriptHealth: null,
getGlobalServicePorts: options.getGlobalServicePorts ?? (async () => undefined),
logger,
emit: (message) => emitted.push(message),
publishStatusUpdate: (message) => published.push(message),
@@ -152,18 +156,42 @@ afterEach(() => {
}
});
test("reads external service-port edits at the next launch without construction IO", async () => {
const home = mkdtempSync(join(tmpdir(), "workspace-service-ports-"));
tempDirs.push(home);
const store = new DaemonConfigStore(home, {
providers: {},
mcp: { enabled: true, injectIntoAgents: false },
});
const { service, spawnCalls } = buildService({
getGlobalServicePorts: () => store.readServicePorts(),
});
writeFileSync(
join(home, "config.json"),
JSON.stringify({ worktrees: { servicePorts: { range: "9000-9999" } } }),
);
await service.start(request);
expect(spawnCalls.map((call) => call.globalServicePorts)).toEqual([{ range: "9000-9999" }]);
});
describe("buildSnapshot", () => {
test("returns no scripts when the service proxy is unavailable", async () => {
const { service } = buildService({ serviceProxy: null });
expect(
service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord),
await service.buildSnapshot({
workspaceId: "ws-1",
cwd: "/tmp/repo",
} as PersistedWorkspaceRecord),
).toEqual([]);
});
test("returns no scripts when the runtime store is unavailable", async () => {
const { service } = buildService({ scriptRuntimeStore: null });
expect(
service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord),
await service.buildSnapshot({
workspaceId: "ws-1",
cwd: "/tmp/repo",
} as PersistedWorkspaceRecord),
).toEqual([]);
});
@@ -172,7 +200,7 @@ describe("buildSnapshot", () => {
tempDirs.push(dir);
const { service } = buildService();
expect(
service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord),
await service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord),
).toEqual([]);
});
@@ -207,7 +235,7 @@ describe("buildSnapshot", () => {
runtimeStore: { get: () => null },
});
expect(service.buildSnapshot(workspace, project)[0]?.hostname).toBe(
expect((await service.buildSnapshot(workspace, project))[0]?.hostname).toBe(
serviceProxy.projectWorkspaceService({
projectSlug: deriveProjectServiceSlug(project),
branchName: workspace.branch,
@@ -352,10 +380,12 @@ describe("start", () => {
daemonPort: 6767,
daemonListenHost: "127.0.0.1",
});
expect(published).toContainEqual({
type: "script_status_update",
payload: { workspaceId: "ws-1", scripts: [] },
});
await expect
.poll(() => published)
.toContainEqual({
type: "script_status_update",
payload: { workspaceId: "ws-1", scripts: [] },
});
expect(emitted).toContainEqual({
type: "start_workspace_script_response",
payload: {
@@ -449,7 +479,7 @@ describe("start", () => {
const serviceProxy = createServiceProxySubsystem({ logger });
const { service, spawnCalls } = buildService({ workspace, project, serviceProxy });
const snapshot = service.buildSnapshot(workspace, project);
const snapshot = await service.buildSnapshot(workspace, project);
await service.start({ ...request, workspaceId: workspace.workspaceId });
const started = spawnCalls[0]!;
@@ -43,7 +43,7 @@ export interface WorkspaceScriptsService {
buildSnapshot(
workspace: PersistedWorkspaceRecord,
project?: PersistedProjectRecord | null,
): WorkspaceScriptsPayload;
): Promise<WorkspaceScriptsPayload>;
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise<void>;
list(workspaceId: string): Promise<WorkspaceScriptPayload[]>;
launch(input: { workspaceId: string; scriptName: string }): Promise<WorkspaceScriptPayload>;
@@ -62,7 +62,7 @@ export function createWorkspaceScriptsService(deps: {
getDaemonTcpHost: (() => string | null) | null;
serviceProxyPublicBaseUrl: string | null;
resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null;
globalServicePorts?: PaseoServicePortAllocation;
getGlobalServicePorts: () => Promise<PaseoServicePortAllocation | undefined>;
logger: pino.Logger;
emit: (message: SessionOutboundMessage) => void;
// Script status is daemon state, so it reaches every client, not only the requester.
@@ -81,7 +81,7 @@ export function createWorkspaceScriptsService(deps: {
getDaemonTcpHost,
serviceProxyPublicBaseUrl,
resolveScriptHealth,
globalServicePorts,
getGlobalServicePorts,
logger,
emit,
publishStatusUpdate,
@@ -110,17 +110,17 @@ export function createWorkspaceScriptsService(deps: {
};
}
function buildSnapshot(
async function buildSnapshot(
workspace: PersistedWorkspaceRecord,
project: PersistedProjectRecord | null = null,
): WorkspaceScriptsPayload {
): Promise<WorkspaceScriptsPayload> {
if (!serviceProxy || !scriptRuntimeStore) {
return [];
}
return buildWorkspaceScriptPayloads({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.cwd,
paseoConfig: readPaseoConfigForProjection(workspace.cwd, logger),
paseoConfig: await readPaseoConfigForProjection(workspace.cwd, logger),
serviceProxy,
runtimeStore: scriptRuntimeStore,
daemonPort: getDaemonTcpPort?.() ?? null,
@@ -137,7 +137,7 @@ export function createWorkspaceScriptsService(deps: {
const project = await projectRegistry.get(workspace.projectId);
publishStatusUpdate({
type: "script_status_update",
payload: { workspaceId, scripts: buildSnapshot(workspace, project) },
payload: { workspaceId, scripts: await buildSnapshot(workspace, project) },
});
} catch (error) {
logger.warn({ err: error, workspaceId }, "Failed to project workspace script status");
@@ -172,6 +172,7 @@ export function createWorkspaceScriptsService(deps: {
async function launchProcess(input: { workspaceId: string; scriptName: string }) {
const available = requireAvailable();
const globalServicePorts = await getGlobalServicePorts();
const workspace = await getWorkspace(input.workspaceId);
await assertAutomationAllowed(workspace.workspaceId);
const project = await projectRegistry.get(workspace.projectId);
@@ -202,7 +203,7 @@ export function createWorkspaceScriptsService(deps: {
scriptName: string;
}): Promise<WorkspaceScriptPayload> {
const { workspace, project } = await launchProcess(input);
const script = buildSnapshot(workspace, project).find(
const script = (await buildSnapshot(workspace, project)).find(
(entry) => entry.scriptName === input.scriptName,
);
if (!script) {
@@ -230,7 +231,7 @@ export function createWorkspaceScriptsService(deps: {
// The launcher's terminal exit listener owns route removal and runtime state updates.
await available.terminalManager.killTerminalAndWait(runtime.terminalId);
const script = buildSnapshot(workspace, project).find(
const script = (await buildSnapshot(workspace, project)).find(
(entry) => entry.scriptName === input.scriptName,
);
if (!script) {
@@ -1,7 +1,11 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { setImmediate } from "node:timers/promises";
import { performance } from "node:perf_hooks";
import { SessionDelivery } from "../session/owned-subscriptions/index.js";
import { highlightCode, highlightCodeWithBudget } from "@getpaseo/highlight";
import {
parseDiff,
reconstructNewFile,
@@ -819,3 +823,152 @@ index 1111111..2222222 100644
}
});
});
describe("diff highlighting budget", () => {
it.each(["supplied", "local"] as const)(
"keeps a one-line diff on a 5 MiB %s source responsive using hunk tokens",
async (source) => {
const cwd = mkdtempSync(join(tmpdir(), "diff-highlight-budget-"));
const oldFileContent = "const value = Math.max(42, 7);\n".repeat(
Math.ceil((5 * 1024 * 1024) / 30),
);
const newFileContent = oldFileContent.replace("42", "43");
writeFileSync(join(cwd, "example.ts"), newFileContent);
const file = parseDiff(`diff --git a/example.ts b/example.ts
--- a/example.ts
+++ b/example.ts
@@ -1 +1 @@
-const value = Math.max(42, 7);
+const value = Math.max(43, 7);`)[0];
let lastTick = performance.now();
let longestGap = 0;
const timer = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - lastTick);
lastTick = now;
}, 1);
try {
await setImmediate();
const highlighted = await highlightDiffWithFileContent(
file,
cwd,
source === "supplied" ? { oldFileContent } : {},
);
await new Promise((resolve) => setTimeout(resolve, 5));
console.info({ sourceBytes: Buffer.byteLength(oldFileContent), longestGap });
expect(longestGap).toBeLessThan(50);
expect(highlighted).toEqual(highlightDiffFromHunks(file));
} finally {
clearInterval(timer);
rmSync(cwd, { recursive: true, force: true });
}
},
30_000,
);
it("preserves byte-identical tokens below the budget, including multiline syntax", async () => {
const oldFileContent = "/*\nold comment\n*/\nconst value = 42;";
const newFileContent = oldFileContent.replace("old", "new");
const file = parseDiff(`diff --git a/example.ts b/example.ts
--- a/example.ts
+++ b/example.ts
@@ -2 +2 @@
-old comment
+new comment`)[0];
const highlighted = await highlightDiffWithFileContent(file, ".", {
oldFileContent,
newFileContent,
});
expect(JSON.stringify(highlighted.hunks[0].lines[1].tokens)).toBe(
JSON.stringify(highlightCode(oldFileContent, file.path)[1]),
);
expect(JSON.stringify(highlighted.hunks[0].lines[2].tokens)).toBe(
JSON.stringify(highlightCode(newFileContent, file.path)[1]),
);
});
});
it("disconnecting the requesting source stops a parser between highlight steps", async () => {
const delivery = new SessionDelivery(() => {});
const source = {};
delivery.attach(source, true);
const yielded = Promise.withResolvers<void>();
let result: Awaited<ReturnType<typeof highlightCodeWithBudget>> = [];
const pending = delivery.request(source, { type: "ping", requestId: "highlight" }, async () => {
result = await highlightCodeWithBudget(
"const value = Math.max(42, 7);\n".repeat(3_000),
"file.ts",
{
signal: delivery.requestSignal,
yieldToEventLoop: () => {
yielded.resolve();
return setImmediate();
},
},
);
});
await yielded.promise;
await delivery.detach(source);
await pending;
expect(result).toBeNull();
});
it("keeps real session.ts diffs highlighted and byte-identical under the source budget", async () => {
const cwd = mkdtempSync(join(tmpdir(), "diff-highlight-real-source-"));
try {
const oldFileContent = readFileSync(new URL("../session.ts", import.meta.url), "utf8");
const newFileContent = oldFileContent.replace(
"WorkspaceRuntimeStore",
"WorkspaceRuntimeSnapshot",
);
writeFileSync(join(cwd, "session.ts"), newFileContent);
const oldLine = oldFileContent.split("\n")[0];
const newLine = newFileContent.split("\n")[0];
const [file] = parseDiff(`diff --git a/session.ts b/session.ts
--- a/session.ts
+++ b/session.ts
@@ -1 +1 @@
-${oldLine}
+${newLine}`);
const highlighted = await highlightDiffWithFileContent(file, cwd, { oldFileContent });
const removed = highlighted.hunks[0].lines.find((line) => line.type === "remove");
const added = highlighted.hunks[0].lines.find((line) => line.type === "add");
expect(removed?.tokens).toEqual(highlightCode(oldFileContent, "session.ts")[0]);
expect(added?.tokens).toEqual(highlightCode(newFileContent, "session.ts")[0]);
expect(added?.tokens?.some((token) => token.style !== null)).toBe(true);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
it("highlights a one-line hunk from an over-budget JSON source without blocking the loop", async () => {
const cwd = mkdtempSync(join(tmpdir(), "diff-highlight-large-json-"));
const oldFileContent = `{\n${' "value": 42,\n'.repeat(100_000)} "last": true\n}`;
const newFileContent = oldFileContent.replace("42", "43");
writeFileSync(join(cwd, "package-lock.json"), newFileContent);
const [file] = parseDiff(`diff --git a/package-lock.json b/package-lock.json
--- a/package-lock.json
+++ b/package-lock.json
@@ -2 +2 @@
- "value": 42,
+ "value": 43,`);
let last = performance.now(),
longestGap = 0;
const timer = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - last);
last = now;
}, 1);
try {
const result = await highlightDiffWithFileContent(file, cwd, { oldFileContent });
await new Promise((resolve) => setTimeout(resolve, 5));
expect(longestGap).toBeLessThan(50);
expect(result.hunks[0].lines.find((line) => line.type === "add")?.tokens).toContainEqual({
text: "43",
style: "number",
});
} finally {
clearInterval(timer);
rmSync(cwd, { recursive: true, force: true });
}
});
@@ -1,6 +1,14 @@
import { readFile } from "node:fs/promises";
import { open } from "node:fs/promises";
import { constants } from "node:fs";
import { setImmediate } from "node:timers/promises";
import { resolve } from "node:path";
import { highlightCode, isLanguageSupported, type HighlightToken } from "@getpaseo/highlight";
import {
highlightCode,
highlightCodeWithBudget,
HIGHLIGHT_MAX_SOURCE_BYTES,
isLanguageSupported,
type HighlightToken,
} from "@getpaseo/highlight";
import { unquoteGitPath } from "../../utils/git-quoted-path.js";
const MAX_DIFF_HIGHLIGHT_LINE_CHARS = 10_000;
@@ -30,11 +38,13 @@ export interface ParsedDiffFile {
}
interface HighlightDiffWithFileContentOptions {
signal?: AbortSignal;
oldFileContent?: string | null;
newFileContent?: string | null;
}
interface ParseAndHighlightDiffOptions {
signal?: AbortSignal;
getOldFileContent?: (file: ParsedDiffFile) => Promise<string | null>;
getNewFileContent?: (file: ParsedDiffFile) => Promise<string | null>;
}
@@ -269,20 +279,71 @@ function buildTokenLookup(
return lookup;
}
function buildFullFileTokenLookup(
async function buildFullFileTokenLookup(
fileContent: string,
path: string,
): Map<number, HighlightToken[]> | null {
if (hasOversizedLine(fileContent)) return null;
signal?: AbortSignal,
): Promise<Map<number, HighlightToken[]> | null> {
const highlighted = await highlightCodeWithBudget(fileContent, path, {
yieldToEventLoop: setImmediate,
signal,
});
if (!highlighted) return null;
return new Map(highlighted.map((tokens, index) => [index + 1, tokens]));
}
const lookup = new Map<number, HighlightToken[]>();
const highlighted = highlightCode(fileContent, path);
for (let i = 0; i < highlighted.length; i++) {
lookup.set(i + 1, highlighted[i]);
async function readHighlightSource(path: string): Promise<string | null> {
let handle;
try {
handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
const stat = await handle.stat();
if (!stat.isFile() || stat.size > HIGHLIGHT_MAX_SOURCE_BYTES) return null;
const buffer = Buffer.alloc(Math.min(stat.size, HIGHLIGHT_MAX_SOURCE_BYTES) + 1);
let offset = 0;
while (offset < buffer.length) {
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, null);
if (bytesRead === 0) break;
offset += bytesRead;
}
// A full buffer includes the extra byte: the source grew after stat.
return offset === buffer.length ? null : buffer.toString("utf8", 0, offset);
} catch {
return null;
} finally {
await handle?.close();
}
}
return lookup;
function exceedsSourceBudget(content: string): boolean {
return (
content.length > HIGHLIGHT_MAX_SOURCE_BYTES ||
Buffer.byteLength(content) > HIGHLIGHT_MAX_SOURCE_BYTES
);
}
async function buildBudgetedReconstructedTokens(file: ParsedDiffFile, signal?: AbortSignal) {
const newLines = reconstructNewFile(file.hunks);
const oldLines = reconstructOldFile(file.hunks);
const build = async (lines: Map<number, string>) => {
// Sparse hunks can be far apart. Admit the span before filling its gaps.
const numbers = Array.from(lines.keys());
if (numbers.length && numbers[numbers.length - 1] - numbers[0] > HIGHLIGHT_MAX_SOURCE_BYTES)
return null;
let bytes = 0;
for (const line of lines.values()) {
bytes += Buffer.byteLength(line) + 1;
if (bytes > HIGHLIGHT_MAX_SOURCE_BYTES) return null;
}
const highlighted = await highlightCodeWithBudget(buildFileContent(lines), file.path, {
yieldToEventLoop: setImmediate,
signal,
});
return highlighted ? buildTokenLookup(lines, highlighted) : null;
};
const newTokensByLine = await build(newLines);
if (!newTokensByLine) return null;
const oldTokensByLine = await build(oldLines);
return oldTokensByLine ? { newTokensByLine, oldTokensByLine } : null;
}
function buildReconstructedTokenLookups(file: ParsedDiffFile): {
@@ -328,36 +389,50 @@ export async function highlightDiffWithFileContent(
cwd: string,
options: HighlightDiffWithFileContentOptions = {},
): Promise<ParsedDiffFile> {
if (!isLanguageSupported(file.path) || hasOversizedDiffLine(file)) {
return file;
}
if (!isDiffHighlightEligible(file, options)) return file;
let newTokensByLine: Map<number, HighlightToken[]> | null = null;
const oldTokensByLine =
typeof options.oldFileContent === "string"
? buildFullFileTokenLookup(options.oldFileContent, file.path)
: null;
if (typeof options.newFileContent === "string") {
newTokensByLine = buildFullFileTokenLookup(options.newFileContent, file.path);
} else {
try {
const fileContent = await readFile(resolve(cwd, file.path), "utf-8");
newTokensByLine = buildFullFileTokenLookup(fileContent, file.path);
} catch {
// Deleted or unavailable files use reconstructed tokens below.
}
}
const newContent =
typeof options.newFileContent === "string"
? options.newFileContent
: await readHighlightSource(resolve(cwd, file.path));
if (options.signal?.aborted) return file;
if (newTokensByLine && oldTokensByLine)
return applyTokensToHunks(file, newTokensByLine, oldTokensByLine);
const reconstructedTokens = buildReconstructedTokenLookups(file);
const oldTokens = await highlightAvailableSource(
options.oldFileContent,
file.path,
options.signal,
);
if (options.signal?.aborted) return file;
const newTokens = await highlightAvailableSource(newContent, file.path, options.signal);
if (options.signal?.aborted) return file;
if (oldTokens && newTokens) return applyTokensToHunks(file, newTokens, oldTokens);
const reconstructed = await buildBudgetedReconstructedTokens(file, options.signal);
if (!reconstructed || options.signal?.aborted) return file;
return applyTokensToHunks(
file,
newTokensByLine ?? reconstructedTokens.newTokensByLine,
oldTokensByLine ?? reconstructedTokens.oldTokensByLine,
newTokens ?? reconstructed.newTokensByLine,
oldTokens ?? reconstructed.oldTokensByLine,
);
}
function isDiffHighlightEligible(
file: ParsedDiffFile,
options: HighlightDiffWithFileContentOptions,
): boolean {
return !options.signal?.aborted && isLanguageSupported(file.path) && !hasOversizedDiffLine(file);
}
async function highlightAvailableSource(
content: string | null | undefined,
path: string,
signal?: AbortSignal,
): Promise<Map<number, HighlightToken[]> | null> {
// Preserve the existing hunk-context fallback for oversized individual lines.
if (typeof content !== "string" || exceedsSourceBudget(content) || hasOversizedLine(content))
return null;
return buildFullFileTokenLookup(content, path, signal);
}
function applyTokensToHunks(
file: ParsedDiffFile,
newTokensByLine: Map<number, HighlightToken[]>,
@@ -406,20 +481,24 @@ export async function parseAndHighlightDiff(
): Promise<ParsedDiffFile[]> {
const files = parseDiff(diffText);
const highlightedFiles = await Promise.all(
files.map(async (file) => {
const [oldFileContent, newFileContent] = await Promise.all([
options.getOldFileContent?.(file),
options.getNewFileContent?.(file),
]);
return highlightDiffWithFileContent(file, cwd, {
const highlightedFiles: ParsedDiffFile[] = [];
for (const file of files) {
if (options.signal?.aborted) {
highlightedFiles.push(file);
continue;
}
const [oldFileContent, newFileContent] = await Promise.all([
options.getOldFileContent?.(file),
options.getNewFileContent?.(file),
]);
highlightedFiles.push(
await highlightDiffWithFileContent(file, cwd, {
oldFileContent: oldFileContent ?? undefined,
newFileContent: newFileContent ?? undefined,
});
}),
);
signal: options.signal,
}),
);
}
return highlightedFiles;
}
@@ -358,7 +358,7 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry {
get: async () => null,
update: async () => null,
upsert: async () => {},
archive: async () => {},
archive: async () => false,
remove: async () => {},
};
}

Some files were not shown because too many files have changed in this diff Show More