Compare commits

...
57 changed files with 1753 additions and 484 deletions
+7 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.1.60-beta.2 - 2026-04-22
## 0.1.60 - 2026-04-22
### Added
- Scripts and services per worktree — define named commands in `paseo.json`, and long-running services get supervised with their own ports and nice proxy URLs like `http://web.my-app.localhost:6767`. See the [worktrees guide](https://paseo.sh/docs/worktrees).
@@ -28,6 +28,7 @@
- Escape reliably interrupts the active agent.
- Checking out a pull request from a fork lands on an owner-prefixed branch so multiple forks don't collide.
- `paseo ls` defaults to active agents; pass `-a` to include archived.
- GitHub branch and PR picker loads faster — queries are deferred until the picker opens.
### Fixed
- Composer textarea shrinks back down after sending on web.
@@ -42,6 +43,11 @@
- iOS image picker no longer leaves the screen unresponsive after cancelling.
- Archiving a worktree recovers cleanly if a previous attempt was interrupted.
- Images in agent messages with `~`-prefixed paths load instead of spinning forever.
- Tool call blocks expand correctly on mobile while an agent is still streaming.
- Timeline no longer stutters when catch-up and projected ranges overlap.
- Codex no longer flashes idle when a replacement turn is in progress.
- Branch state recovers correctly when a rebase is in progress.
- Workspace hover card no longer clips near screen edges.
## 0.1.59 - 2026-04-16
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-5exQLdsyyKhlQzrcpBKr3xidAZ3RvQ6wvzf2XyaSPJ4=";
npmDepsHash = "sha256-p7luf7/eeQR5seXerJd42x7gY73qqOecStNZN/3wIlk=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+19 -19
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -37385,16 +37385,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.60-beta.2",
"@getpaseo/highlight": "0.1.60-beta.2",
"@getpaseo/server": "0.1.60-beta.2",
"@getpaseo/expo-two-way-audio": "0.1.60",
"@getpaseo/highlight": "0.1.60",
"@getpaseo/server": "0.1.60",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -37539,11 +37539,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.60-beta.2",
"@getpaseo/server": "0.1.60-beta.2",
"@getpaseo/relay": "0.1.60",
"@getpaseo/server": "0.1.60",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37584,11 +37584,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.60-beta.2",
"@getpaseo/server": "0.1.60-beta.2",
"@getpaseo/cli": "0.1.60",
"@getpaseo/server": "0.1.60",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -37622,7 +37622,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -37823,7 +37823,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37849,7 +37849,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37865,14 +37865,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.60-beta.2",
"@getpaseo/relay": "0.1.60-beta.2",
"@getpaseo/highlight": "0.1.60",
"@getpaseo/relay": "0.1.60",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-coding-agent": "^0.67.68",
"@modelcontextprotocol/sdk": "^1.20.1",
@@ -38627,7 +38627,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"private": true,
"scripts": {
"start": "APP_VARIANT=development expo start",
@@ -32,9 +32,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.60-beta.2",
"@getpaseo/highlight": "0.1.60-beta.2",
"@getpaseo/server": "0.1.60-beta.2",
"@getpaseo/expo-two-way-audio": "0.1.60",
"@getpaseo/highlight": "0.1.60",
"@getpaseo/server": "0.1.60",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createLocalFileAttachmentStore } from "./local-file-attachment-store";
const fileSystemMock = vi.hoisted(() => ({
getInfoAsync: vi.fn(async (uri: string) => {
if (uri.endsWith("/preview-assets/")) {
return { exists: true, isDirectory: true };
}
return { exists: true, isDirectory: false, size: 4 };
}),
makeDirectoryAsync: vi.fn(async () => {}),
writeAsStringAsync: vi.fn(async () => {}),
copyAsync: vi.fn(async () => {}),
readAsStringAsync: vi.fn(async () => "AAECAw=="),
deleteAsync: vi.fn(async () => {}),
readDirectoryAsync: vi.fn(async () => []),
}));
vi.mock("expo-file-system/legacy", () => ({
cacheDirectory: "file:///cache/",
EncodingType: { Base64: "base64" },
getInfoAsync: fileSystemMock.getInfoAsync,
makeDirectoryAsync: fileSystemMock.makeDirectoryAsync,
writeAsStringAsync: fileSystemMock.writeAsStringAsync,
copyAsync: fileSystemMock.copyAsync,
readAsStringAsync: fileSystemMock.readAsStringAsync,
deleteAsync: fileSystemMock.deleteAsync,
readDirectoryAsync: fileSystemMock.readDirectoryAsync,
}));
describe("local file attachment store", () => {
beforeEach(() => {
fileSystemMock.getInfoAsync.mockClear();
fileSystemMock.makeDirectoryAsync.mockClear();
fileSystemMock.writeAsStringAsync.mockClear();
fileSystemMock.copyAsync.mockClear();
fileSystemMock.readAsStringAsync.mockClear();
fileSystemMock.deleteAsync.mockClear();
fileSystemMock.readDirectoryAsync.mockClear();
});
it("writes raw base64 sources directly to the managed file path", async () => {
const store = createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "preview-assets",
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
});
const attachment = await store.save({
id: "preview_8_test",
mimeType: "image/png",
fileName: "result.png",
source: { kind: "base64", base64: "AAECAw==" },
});
expect(fileSystemMock.writeAsStringAsync).toHaveBeenCalledWith(
"file:///cache/preview-assets/preview_8_test.png",
"AAECAw==",
{ encoding: "base64" },
);
expect(attachment).toMatchObject({
id: "preview_8_test",
mimeType: "image/png",
storageType: "native-file",
storageKey: "/cache/preview-assets/preview_8_test.png",
fileName: "result.png",
byteSize: 4,
});
});
});
@@ -72,6 +72,13 @@ async function writeFromSource(input: {
return;
}
if (input.source.kind === "base64") {
await FileSystem.writeAsStringAsync(input.targetUri, input.source.base64, {
encoding: FileSystem.EncodingType.Base64,
});
return;
}
const base64 = await blobToBase64(input.source.blob);
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
encoding: FileSystem.EncodingType.Base64,
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it } from "vitest";
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
import { __setAttachmentStoreForTests } from "./store";
import { encodeAttachmentsForSend, persistAttachmentFromBase64 } from "./service";
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
return {
id: input.id ?? "att_1",
mimeType: input.mimeType ?? "image/png",
storageType: input.storageType ?? "web-indexeddb",
storageKey: input.storageKey ?? "att_1",
fileName: input.fileName,
byteSize: input.byteSize,
createdAt: input.createdAt ?? 1700000000000,
};
}
function createRecordingStore(): AttachmentStore & {
savedSources: SaveAttachmentInput[];
releasedUrls: string[];
} {
const savedSources: SaveAttachmentInput[] = [];
const releasedUrls: string[] = [];
return {
storageType: "web-indexeddb",
savedSources,
releasedUrls,
async save(input) {
savedSources.push(input);
return createAttachment({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
byteSize: 4,
});
},
async encodeBase64({ attachment }) {
return `${attachment.id}:base64`;
},
async resolvePreviewUrl({ attachment }) {
return `blob:${attachment.id}`;
},
async releasePreviewUrl({ url }) {
releasedUrls.push(url);
},
async delete() {},
async garbageCollect() {},
};
}
describe("attachment service", () => {
afterEach(() => {
__setAttachmentStoreForTests(null);
});
it("persists raw base64 without requiring a data URL wrapper", async () => {
const store = createRecordingStore();
__setAttachmentStoreForTests(store);
const attachment = await persistAttachmentFromBase64({
id: "att_base64",
base64: "AAECAw==",
mimeType: "image/png",
fileName: "image.png",
});
expect(attachment).toEqual({
id: "att_base64",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "att_1",
fileName: "image.png",
byteSize: 4,
createdAt: 1700000000000,
});
expect(store.savedSources).toEqual([
{
id: "att_base64",
mimeType: "image/png",
fileName: "image.png",
source: { kind: "base64", base64: "AAECAw==" },
},
]);
});
it("keeps provider send output byte-compatible", async () => {
const store = createRecordingStore();
__setAttachmentStoreForTests(store);
const attachment = createAttachment({ id: "att_send", mimeType: "image/jpeg" });
await expect(encodeAttachmentsForSend([attachment])).resolves.toEqual([
{ data: "att_send:base64", mimeType: "image/jpeg" },
]);
});
});
+15
View File
@@ -31,6 +31,21 @@ export async function persistAttachmentFromDataUrl(input: {
});
}
export async function persistAttachmentFromBase64(input: {
base64: string;
mimeType?: string;
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
const store = await getAttachmentStore();
return await store.save({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
source: { kind: "base64", base64: input.base64 },
});
}
export async function persistAttachmentFromFileUri(input: {
uri: string;
mimeType?: string;
+1
View File
@@ -23,6 +23,7 @@ export type ComposerAttachment =
| { kind: "github_pr"; item: GitHubSearchItem };
export type AttachmentDataSource =
| { kind: "base64"; base64: string }
| { kind: "blob"; blob: Blob }
| { kind: "data_url"; dataUrl: string }
| { kind: "file_uri"; uri: string };
+33 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { pathToFileUri } from "./utils";
import { createImageSourceCacheKey, parseDataUrl, parseImageDataUrl, pathToFileUri } from "./utils";
describe("pathToFileUri", () => {
it("converts POSIX absolute paths to file URIs", () => {
@@ -22,3 +22,35 @@ describe("pathToFileUri", () => {
expect(pathToFileUri("relative/path")).toBe("relative/path");
});
});
describe("parseDataUrl", () => {
it("accepts base64 data URLs with media-type parameters", () => {
expect(parseDataUrl("data:image/png;charset=utf-8;name=preview;base64,AAECAw==")).toEqual({
mimeType: "image/png",
base64: "AAECAw==",
});
});
it("rejects non-base64 data URLs", () => {
expect(() => parseDataUrl("data:image/png,not-base64")).toThrow(
"Attachment data URL is not base64 encoded.",
);
});
});
describe("parseImageDataUrl", () => {
it("returns a compact cache key for image data URLs", () => {
const dataUrl = `data:image/png;base64,${"a".repeat(512)}`;
expect(parseImageDataUrl(dataUrl)).toMatchObject({
mimeType: "image/png",
base64: "a".repeat(512),
});
expect(createImageSourceCacheKey(dataUrl)).toMatch(/^data-image:image\/png:512:/);
expect(createImageSourceCacheKey(dataUrl)).not.toContain("a".repeat(128));
});
it("ignores non-image data URLs", () => {
expect(parseImageDataUrl("data:text/plain;base64,SGVsbG8=")).toBeNull();
});
});
+63 -2
View File
@@ -14,11 +14,17 @@ export function normalizeMimeType(input: string | undefined | null): string {
}
export function parseDataUrl(dataUrl: string): { mimeType: string; base64: string } {
const match = /^data:([^;,]+)?;base64,(.+)$/i.exec(dataUrl);
const match = /^data:([^,]*),([\s\S]+)$/i.exec(dataUrl.trim());
if (!match) {
throw new Error("Malformed data URL for attachment.");
}
const [, mimeTypeRaw, base64] = match;
const metadata = match[1] ?? "";
const base64 = match[2]?.replace(/\s/g, "");
const [mimeTypeRaw, ...parameters] = metadata.split(";").map((part) => part.trim());
const isBase64 = parameters.some((part) => part.toLowerCase() === "base64");
if (!isBase64) {
throw new Error("Attachment data URL is not base64 encoded.");
}
if (!base64) {
throw new Error("Attachment data URL is missing base64 payload.");
}
@@ -28,6 +34,61 @@ export function parseDataUrl(dataUrl: string): { mimeType: string; base64: strin
};
}
function hashString(value: string): string {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(36);
}
export function parseImageDataUrl(
uri: string,
): { mimeType: string; base64: string; cacheKey: string } | null {
if (!uri.trim().toLowerCase().startsWith("data:image/")) {
return null;
}
try {
const parsed = parseDataUrl(uri);
if (!parsed.mimeType.toLowerCase().startsWith("image/")) {
return null;
}
return {
...parsed,
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(parsed.base64)}`,
};
} catch {
return null;
}
}
export function createImageSourceCacheKey(source: string): string {
return parseImageDataUrl(source)?.cacheKey ?? source;
}
export function getFileNameFromPath(path: string | null | undefined): string | null {
const trimmed = path?.trim();
if (!trimmed) {
return null;
}
const normalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, "");
const fileName = normalized.split("/").pop()?.trim();
return fileName || null;
}
export function createPreviewAttachmentId(input: {
base64: string;
mimeType: string;
path?: string | null;
}): string {
const path = input.path?.trim() ?? "";
const hash = hashString(`${input.mimeType}\0${path}\0${input.base64}`);
return `preview_${input.base64.length}_${hash}`;
}
export async function blobToBase64(blob: Blob): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
@@ -74,6 +74,15 @@ function runTx<T>(
});
}
function base64ToBlob(input: { base64: string; mimeType: string }): Blob {
const binary = atob(input.base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type: input.mimeType });
}
async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> {
const source = input.source;
if (source.kind === "blob") {
@@ -96,6 +105,14 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
};
}
if (source.kind === "base64") {
const mimeType = normalizeMimeType(input.mimeType);
return {
blob: base64ToBlob({ base64: source.base64, mimeType }),
mimeType,
};
}
const response = await fetch(source.uri);
const blob = await response.blob();
const mimeType = normalizeMimeType(input.mimeType ?? blob.type);
+20 -15
View File
@@ -22,6 +22,7 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import type { Agent } from "@/stores/session-store";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
interface AgentListProps {
agents: AggregatedAgent[];
@@ -280,6 +281,7 @@ export function AgentList({
const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = useIsCompactFormFactor();
const { archiveAgent } = useArchiveAgent();
const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
@@ -320,20 +322,23 @@ export function AgentList({
[isActionSheetVisible, onAgentSelect],
);
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
const isRunning = agent.status === "running" || agent.status === "initializing";
if (isRunning) {
setActionAgent(agent);
return;
}
const handleAgentLongPress = useCallback(
(agent: AggregatedAgent) => {
const isRunning = agent.status === "running" || agent.status === "initializing";
if (isRunning) {
setActionAgent(agent);
return;
}
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
if (!client) {
setActionAgent(agent);
return;
}
void client.archiveAgent(agent.id);
}, []);
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
if (!client) {
setActionAgent(agent);
return;
}
void archiveAgent({ serverId: agent.serverId, agentId: agent.id }).catch(() => {});
},
[archiveAgent],
);
const handleCloseActionSheet = useCallback(() => {
setActionAgent(null);
@@ -344,9 +349,9 @@ export function AgentList({
return;
}
// Timeout errors are swallowed — the daemon will still process the archive
void actionClient.archiveAgent(actionAgent.id).catch(() => {});
void archiveAgent({ serverId: actionAgent.serverId, agentId: actionAgent.id }).catch(() => {});
setActionAgent(null);
}, [actionAgent, actionClient]);
}, [actionAgent, actionClient, archiveAgent]);
const flatItems = useMemo((): FlatListItem[] => {
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
+18 -1
View File
@@ -344,7 +344,7 @@ vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
vi.mock("@/utils/scroll-jank-investigation", () => ({
vi.mock("@/utils/scroll-jank", () => ({
markScrollInvestigationRender: markScrollInvestigationRenderMock,
markScrollInvestigationEvent: vi.fn(),
}));
@@ -834,6 +834,23 @@ describe("Composer attachments", () => {
expect(combobox.dataset.anchor).toBe("attached");
});
it("lazily searches GitHub only after the GitHub combobox opens", async () => {
renderComposer();
await flushAsyncWork();
expect(mockClient.searchGitHub).not.toHaveBeenCalled();
click(queryByTestId("message-input-attach-button")!);
click(queryByTestId("message-input-attachment-menu-item-github")!);
await findByTestId("composer-github-combobox");
expect(mockClient.searchGitHub).toHaveBeenCalledWith({
cwd: "/repo",
query: "",
limit: 20,
});
});
it("closes the GitHub combobox after selecting an item", async () => {
renderComposer();
+1 -1
View File
@@ -790,7 +790,7 @@ export function Composer({
limit: 20,
});
},
enabled: isConnected && !!client && cwd.trim().length > 0,
enabled: isGithubPickerOpen && isConnected && !!client && cwd.trim().length > 0,
staleTime: 30_000,
});
+51 -3
View File
@@ -25,6 +25,10 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isWeb } from "@/constants/platform";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBase64 } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
interface CodeLineProps {
tokens: HighlightToken[];
@@ -40,6 +44,7 @@ interface FilePreviewBodyProps {
showDesktopWebScrollbar: boolean;
isMobile: boolean;
filePath: string;
imagePreviewUri: string | null;
}
function trimNonEmpty(value: string | null | undefined): string | null {
@@ -60,6 +65,32 @@ function formatFileSize({ size }: { size: number }): string {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
async function createFilePanePreview(file: ExplorerFile | null): Promise<{
file: ExplorerFile | null;
imageAttachment: AttachmentMetadata | null;
}> {
if (!file || file.kind !== "image" || !file.content) {
return { file, imageAttachment: null };
}
const { content: _content, ...imageFile } = file;
const imageAttachment = await persistAttachmentFromBase64({
id: createPreviewAttachmentId({
base64: file.content,
mimeType: file.mimeType ?? "image/png",
path: file.path,
}),
base64: file.content,
mimeType: file.mimeType,
fileName: getFileNameFromPath(file.path),
});
return {
file: imageFile,
imageAttachment,
};
}
const CodeLine = React.memo(function CodeLine({
tokens,
lineNumber,
@@ -116,6 +147,7 @@ function FilePreviewBody({
showDesktopWebScrollbar,
isMobile,
filePath,
imagePreviewUri,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const isDark = theme.colorScheme === "dark";
@@ -230,7 +262,16 @@ function FilePreviewBody({
);
}
if (preview.kind === "image" && preview.content) {
if (preview.kind === "image") {
if (!imagePreviewUri) {
return (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</View>
);
}
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
@@ -245,7 +286,7 @@ function FilePreviewBody({
>
<RNImage
source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
uri: imagePreviewUri,
}}
style={styles.previewImage}
resizeMode="contain"
@@ -292,11 +333,17 @@ export function FilePane({
normalizedFilePath,
"file",
);
return { file: payload.file ?? null, error: payload.error ?? null };
const preview = await createFilePanePreview(payload.file ?? null);
return {
file: preview.file,
imageAttachment: preview.imageAttachment,
error: payload.error ?? null,
};
},
staleTime: 5_000,
refetchOnMount: true,
});
const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null);
return (
<View style={styles.container} testID="workspace-file-pane">
@@ -312,6 +359,7 @@ export function FilePane({
showDesktopWebScrollbar={showDesktopWebScrollbar}
isMobile={isMobile}
filePath={filePath}
imagePreviewUri={imagePreviewUri}
/>
</View>
);
+52 -13
View File
@@ -78,11 +78,17 @@ import {
setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import {
createPreviewAttachmentId,
getFileNameFromPath,
parseImageDataUrl,
} from "@/attachments/utils";
export type { InlinePathTarget } from "@/utils/inline-path";
import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBase64, persistAttachmentFromDataUrl } from "@/attachments/service";
import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
@@ -595,6 +601,7 @@ function AssistantMarkdownImage({
() => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot],
);
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
const containerStyle = useMemo<StyleProp<ViewStyle>>(
() => ({
marginTop: hasLeadingContent ? theme.spacing[4] : 0,
@@ -621,16 +628,44 @@ function AssistantMarkdownImage({
if (payload.error) {
throw new Error(payload.error);
}
if (!payload.file || payload.file.kind !== "image" || !payload.file.content) {
const file = payload.file;
if (!file || file.kind !== "image" || !file.content) {
throw new Error("Image preview unavailable.");
}
return `data:${payload.file.mimeType ?? "image/png"};base64,${payload.file.content}`;
return await persistAttachmentFromBase64({
id: createPreviewAttachmentId({
base64: file.content,
mimeType: file.mimeType ?? "image/png",
path: file.path || resolution.path,
}),
base64: file.content,
mimeType: file.mimeType,
fileName: getFileNameFromPath(file.path || resolution.path),
});
},
});
const dataImageQuery = useQuery({
queryKey: ["assistantMarkdownDataImage", dataImage?.cacheKey ?? null],
enabled: dataImage !== null,
staleTime: 30_000,
queryFn: async () => {
if (!dataImage) {
return null;
}
return await persistAttachmentFromDataUrl({
id: createPreviewAttachmentId(dataImage),
dataUrl: source,
mimeType: dataImage.mimeType,
});
},
});
const directUri = resolution?.kind === "direct" ? resolution.uri : null;
const resolvedUri = directUri ?? query.data ?? null;
const fileAssetUri = useAttachmentPreviewUrl(query.data);
const dataImageAssetUri = useAttachmentPreviewUrl(dataImageQuery.data);
const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null;
const resolvedUri = directUri ?? dataImageAssetUri ?? fileAssetUri ?? null;
if (resolvedUri) {
return (
@@ -645,7 +680,7 @@ function AssistantMarkdownImage({
);
}
if (query.isLoading) {
if (query.isLoading || dataImageQuery.isLoading) {
return (
<View
style={[
@@ -668,7 +703,11 @@ function AssistantMarkdownImage({
]}
>
<Text style={assistantMessageStylesheet.imageErrorText}>
{query.error instanceof Error ? query.error.message : "Unable to load image preview."}
{query.error instanceof Error
? query.error.message
: dataImageQuery.error instanceof Error
? dataImageQuery.error.message
: "Unable to load image preview."}
</Text>
</View>
);
@@ -1033,9 +1072,10 @@ const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer(
return (
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
<MaskedView
pointerEvents="none"
style={nativeShimmerTrackStyle}
maskElement={
<View style={nativeShimmerMaskStyle}>
<View pointerEvents="none" style={nativeShimmerMaskStyle}>
<Text style={nativeLabelMaskStyle} numberOfLines={1}>
{label}
</Text>
@@ -1049,8 +1089,8 @@ const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer(
</View>
}
>
<View style={nativeShimmerTrackStyle}>
<Animated.View style={nativeShimmerPeakCombinedStyle}>
<View pointerEvents="none" style={nativeShimmerTrackStyle}>
<Animated.View pointerEvents="none" style={nativeShimmerPeakCombinedStyle}>
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
@@ -2105,10 +2145,9 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
if (previous.isLastInSequence !== next.isLastInSequence) return false;
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
if (previous.testID !== next.testID) return false;
if (Boolean(previous.onToggle) !== Boolean(next.onToggle)) return false;
if (previous.isExpanded && previous.renderDetails !== next.renderDetails) {
return false;
}
if (previous.onToggle !== next.onToggle) return false;
if (previous.onDetailHoverChange !== next.onDetailHoverChange) return false;
if (previous.renderDetails !== next.renderDetails) return false;
return true;
}
@@ -13,10 +13,17 @@ const turnFailedEvent: AgentStreamEventPayload = {
error: "failed",
};
const turnCanceledEvent: AgentStreamEventPayload = {
type: "turn_canceled",
provider: "codex",
reason: "interrupted",
};
describe("session stream lifecycle helpers", () => {
it("derives optimistic terminal lifecycle only when current status is running", () => {
expect(deriveOptimisticLifecycleStatus("running", turnCompletedEvent)).toBe("idle");
expect(deriveOptimisticLifecycleStatus("running", turnFailedEvent)).toBe("error");
expect(deriveOptimisticLifecycleStatus("running", turnCanceledEvent)).toBe(null);
expect(deriveOptimisticLifecycleStatus("initializing", turnCompletedEvent)).toBe(null);
expect(deriveOptimisticLifecycleStatus("idle", turnFailedEvent)).toBe(null);
});
@@ -10,10 +10,13 @@ export function deriveOptimisticLifecycleStatus(
}
switch (event.type) {
case "turn_completed":
case "turn_canceled":
return "idle";
case "turn_failed":
return "error";
case "turn_canceled":
// A canceled turn can be either a final user cancel or an interrupt before
// a replacement turn starts. The daemon snapshot is authoritative here.
return null;
default:
return null;
}
@@ -19,6 +19,7 @@ import {
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
return {
seqStart: seq,
seqEnd: seq,
provider: "claude",
item: { type, text },
timestamp: new Date(1000 + seq).toISOString(),
@@ -318,6 +319,39 @@ describe("processTimelineResponse", () => {
expect(result.cursorChanged).toBe(false);
});
it("requests canonical catch-up when a projected entry overlaps unseen seqs", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-1",
entries: [
{
...makeTimelineEntry(4, "merged assistant message"),
seqEnd: 8,
},
],
},
});
expect(result.tail).toBe(baseTimelineInput.currentTail);
expect(result.cursorChanged).toBe(false);
expect(result.sideEffects).toContainEqual({
type: "catch_up",
cursor: {
epoch: "epoch-1",
endSeq: 5,
},
});
});
it("drops entries with epoch mismatch", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
@@ -602,6 +636,28 @@ describe("processAgentStreamEvent", () => {
expect(result.agent!.status).toBe("error");
});
it("does not derive optimistic idle status on turn_canceled for running agent", () => {
const turnCanceledEvent: AgentStreamEventPayload = {
type: "turn_canceled",
provider: "codex",
reason: "interrupted",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCanceledEvent,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
});
it("does not change agent when status is not running", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
@@ -53,6 +53,7 @@ type InitRequestDirection = "tail" | "after";
type TimelineResponseEntry = {
seqStart: number;
seqEnd: number;
provider: string;
item: Record<string, unknown>;
timestamp: string;
@@ -122,6 +123,7 @@ export function processTimelineResponse(
// ------------------------------------------------------------------
const timelineUnits = payload.entries.map((entry) => ({
seq: entry.seqStart,
seqEnd: entry.seqEnd,
event: {
type: "timeline",
provider: entry.provider,
@@ -200,7 +202,14 @@ export function processTimelineResponse(
gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null;
break;
}
if (decision === "drop_stale" || decision === "drop_epoch") {
if (decision === "drop_stale") {
if (cursor && unit.seqEnd > cursor.endSeq) {
gapCursor = { epoch: cursor.epoch, endSeq: cursor.endSeq };
break;
}
continue;
}
if (decision === "drop_epoch") {
continue;
}
@@ -209,7 +218,7 @@ export function processTimelineResponse(
cursor = {
epoch: payload.epoch,
startSeq: unit.seq,
endSeq: unit.seq,
endSeq: unit.seqEnd,
};
continue;
}
@@ -218,7 +227,7 @@ export function processTimelineResponse(
}
cursor = {
...cursor,
endSeq: unit.seq,
endSeq: unit.seqEnd,
};
}
@@ -86,6 +86,33 @@ describe("desktop attachment store", () => {
});
});
it("saves raw base64 sources via desktop filesystem writes", async () => {
const store = createDesktopAttachmentStore();
const attachment = await store.save({
id: "att_base64",
mimeType: "image/png",
fileName: "inline.png",
source: {
kind: "base64",
base64: "AAECAw==",
},
});
expect(writeDesktopAttachmentBase64Mock).toHaveBeenCalledWith({
attachmentId: "att_base64",
base64: "AAECAw==",
extension: ".png",
});
expect(attachment).toMatchObject({
id: "att_base64",
mimeType: "image/png",
storageType: "desktop-file",
storageKey: "/managed/att_2.png",
fileName: "inline.png",
byteSize: 4,
});
});
it("delegates encode/preview/delete/gc to desktop command path", async () => {
const store = createDesktopAttachmentStore();
const attachment = {
@@ -166,6 +166,16 @@ export function createDesktopAttachmentStore(): AttachmentStore {
});
}
if (input.source.kind === "base64") {
const mimeType = normalizeMimeType(input.mimeType);
return await saveDesktopAttachmentFromBase64({
id,
base64: input.source.base64,
mimeType,
fileName,
});
}
const mimeType = normalizeMimeType(input.mimeType ?? input.source.blob.type);
const base64 = await blobToBase64(input.source.blob);
return await saveDesktopAttachmentFromBase64({
@@ -0,0 +1,3 @@
export function agentHistoryQueryKey(serverId: string | null) {
return ["agentHistory", serverId] as const;
}
+1 -4
View File
@@ -8,6 +8,7 @@ import { useCallback, useMemo } from "react";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
import { agentHistoryQueryKey } from "./agent-history-query-key";
const AGENT_HISTORY_PAGE_LIMIT = 200;
const AGENT_HISTORY_SORT: NonNullable<FetchAgentHistoryOptions["sort"]> = [
@@ -30,10 +31,6 @@ type AgentHistoryPage = {
pageInfo: FetchAgentHistoryPageInfo;
};
export function agentHistoryQueryKey(serverId: string | null) {
return ["agentHistory", serverId] as const;
}
async function fetchAgentHistoryPage(input: {
client: DaemonClient;
serverId: string;
@@ -3,6 +3,7 @@ import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { agentHistoryQueryKey } from "./agent-history-query-key";
import { __private__, applyArchivedAgentCloseResults } from "./use-archive-agent";
function makeAgent(overrides: Partial<Agent> = {}): Agent {
@@ -112,6 +113,17 @@ describe("useArchiveAgent", () => {
queryClient.setQueryData(["allAgents", "server-a"], {
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
});
queryClient.setQueryData(agentHistoryQueryKey("server-a"), {
pages: [
{
agents: [
{ id: "agent-1", archivedAt: null },
{ id: "agent-2", archivedAt: null },
],
},
],
pageParams: [null],
});
applyArchivedAgentCloseResults({
queryClient,
@@ -131,6 +143,17 @@ describe("useArchiveAgent", () => {
expect(queryClient.getQueryData(["allAgents", "server-a"])).toEqual({
entries: [{ agent: { id: "agent-2" } }],
});
expect(queryClient.getQueryData(agentHistoryQueryKey("server-a"))).toEqual({
pages: [
{
agents: [
{ id: "agent-1", archivedAt: new Date("2026-04-01T04:00:00.000Z") },
{ id: "agent-2", archivedAt: null },
],
},
],
pageParams: [null],
});
});
it("can apply archived agent close results without invalidating cached lists", () => {
@@ -143,6 +166,14 @@ describe("useArchiveAgent", () => {
queryClient.setQueryData(["allAgents", "server-a"], {
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
});
queryClient.setQueryData(agentHistoryQueryKey("server-a"), {
pages: [
{
agents: [{ id: "agent-1", archivedAt: null }],
},
],
pageParams: [null],
});
applyArchivedAgentCloseResults({
queryClient,
@@ -153,5 +184,14 @@ describe("useArchiveAgent", () => {
expect(queryClient.getQueryState(["sidebarAgentsList", "server-a"])?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(["allAgents", "server-a"])?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(agentHistoryQueryKey("server-a"))?.isInvalidated).toBe(false);
expect(queryClient.getQueryData(agentHistoryQueryKey("server-a"))).toEqual({
pages: [
{
agents: [{ id: "agent-1", archivedAt: new Date("2026-04-01T04:00:00.000Z") }],
},
],
pageParams: [null],
});
});
});
+84 -5
View File
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import { agentHistoryQueryKey } from "./agent-history-query-key";
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
@@ -32,6 +33,7 @@ interface AgentsListQueryData {
interface ArchivedAgentListCacheSnapshot {
sidebarAgentsList: AgentsListQueryData | undefined;
allAgents: AgentsListQueryData | undefined;
agentHistory: AgentHistoryQueryData | undefined;
}
interface ArchiveAgentMutationContext {
@@ -39,6 +41,19 @@ interface ArchiveAgentMutationContext {
lists: ArchivedAgentListCacheSnapshot;
}
interface AgentHistoryQueryAgent {
id?: string | null;
archivedAt?: Date | null;
}
interface AgentHistoryQueryPage {
agents?: AgentHistoryQueryAgent[];
}
interface AgentHistoryQueryData {
pages?: AgentHistoryQueryPage[];
}
function toArchiveKey(input: ArchiveAgentInput): string {
const serverId = input.serverId.trim();
const agentId = input.agentId.trim();
@@ -121,6 +136,54 @@ function removeAgentFromCachedLists(queryClient: QueryClient, input: ArchiveAgen
);
}
function markAgentArchivedInHistoryPayload<T extends AgentHistoryQueryData | undefined>(
payload: T,
input: ArchiveAgentInput & { archivedAt: string },
): T {
if (!payload || !Array.isArray(payload.pages) || !input.agentId) {
return payload;
}
const archivedAt = new Date(input.archivedAt);
if (Number.isNaN(archivedAt.getTime())) {
return payload;
}
let changed = false;
const pages = payload.pages.map((page) => {
if (!Array.isArray(page.agents)) {
return page;
}
let pageChanged = false;
const agents = page.agents.map((agent) => {
if (agent.id !== input.agentId) {
return agent;
}
pageChanged = true;
changed = true;
return {
...agent,
archivedAt,
};
});
return pageChanged ? { ...page, agents } : page;
});
return changed ? ({ ...payload, pages } as T) : payload;
}
function markAgentArchivedInHistoryCache(
queryClient: QueryClient,
input: ArchiveAgentInput & { archivedAt: string },
): void {
queryClient.setQueryData<AgentHistoryQueryData | undefined>(
agentHistoryQueryKey(input.serverId),
(current) => markAgentArchivedInHistoryPayload(current, input),
);
}
function getStoredAgentSnapshot(input: ArchiveAgentInput) {
return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId);
}
@@ -161,13 +224,16 @@ function getArchivedAgentListCacheSnapshot(
serverId,
]),
allAgents: queryClient.getQueryData<AgentsListQueryData | undefined>(["allAgents", serverId]),
agentHistory: queryClient.getQueryData<AgentHistoryQueryData | undefined>(
agentHistoryQueryKey(serverId),
),
};
}
function restoreCachedListSnapshot(
function restoreCachedQuerySnapshot<T>(
queryClient: QueryClient,
queryKey: readonly [string, string],
snapshot: AgentsListQueryData | undefined,
queryKey: readonly unknown[],
snapshot: T | undefined,
): void {
if (snapshot === undefined) {
queryClient.removeQueries({ queryKey, exact: true });
@@ -181,12 +247,13 @@ function restoreArchivedAgentListCacheSnapshot(
serverId: string,
snapshot: ArchivedAgentListCacheSnapshot,
): void {
restoreCachedListSnapshot(
restoreCachedQuerySnapshot(
queryClient,
["sidebarAgentsList", serverId],
snapshot.sidebarAgentsList,
);
restoreCachedListSnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents);
restoreCachedQuerySnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents);
restoreCachedQuerySnapshot(queryClient, agentHistoryQueryKey(serverId), snapshot.agentHistory);
}
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
@@ -235,6 +302,11 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
serverId: input.serverId,
agentId: result.agentId,
});
markAgentArchivedInHistoryCache(input.queryClient, {
serverId: input.serverId,
agentId: result.agentId,
archivedAt: result.archivedAt,
});
}
if (input.invalidateQueries ?? true) {
@@ -244,6 +316,9 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
void input.queryClient.invalidateQueries({
queryKey: ["allAgents", input.serverId],
});
void input.queryClient.invalidateQueries({
queryKey: agentHistoryQueryKey(input.serverId),
});
}
}
@@ -324,6 +399,9 @@ export function useArchiveAgent() {
void queryClient.invalidateQueries({
queryKey: ["allAgents", input.serverId],
});
void queryClient.invalidateQueries({
queryKey: agentHistoryQueryKey(input.serverId),
});
},
});
@@ -359,4 +437,5 @@ export const __private__ = {
setAgentArchiving,
isAgentArchiving,
removeAgentFromListPayload,
markAgentArchivedInHistoryPayload,
};
@@ -111,13 +111,29 @@ describe("useHoverSafeZone", () => {
});
act(() => pointerMove(110, 40));
expect(onEnterSafeZone).toHaveBeenCalledTimes(1);
expect(onLeaveSafeZone).not.toHaveBeenCalled();
act(() => pointerMove(300, 40));
expect(onLeaveSafeZone).toHaveBeenCalledTimes(1);
act(() => pointerMove(130, 40));
expect(onEnterSafeZone).toHaveBeenCalledTimes(1);
expect(onEnterSafeZone).toHaveBeenCalledTimes(2);
});
it("refreshes the safe-zone enter callback while moving inside", () => {
const onEnterSafeZone = vi.fn();
const onLeaveSafeZone = vi.fn();
act(() => {
root?.render(<Harness onEnterSafeZone={onEnterSafeZone} onLeaveSafeZone={onLeaveSafeZone} />);
});
act(() => pointerMove(110, 40));
act(() => pointerMove(130, 40));
expect(onEnterSafeZone).toHaveBeenCalledTimes(2);
expect(onLeaveSafeZone).not.toHaveBeenCalled();
});
it("treats leaving the browser window as leaving the safe zone", () => {
@@ -54,8 +54,15 @@ export function useHoverSafeZone({
const contentRect = contentNode ? contentNode.getBoundingClientRect() : null;
const inside = isInsideSafeZone(triggerRect, contentRect, event.clientX, event.clientY);
if (inside) enterSafeZone();
else leaveSafeZone();
if (inside) {
if (wasInside) {
onEnterSafeZone();
return;
}
enterSafeZone();
return;
}
leaveSafeZone();
}
function handlePointerOut(event: PointerEvent) {
@@ -465,6 +465,7 @@ function DraftAgentScreenContent({
},
enabled:
isCreateWorktree &&
isBranchOpen &&
isGitDirectory &&
!isNonGitDirectory &&
Boolean(trimmedWorkingDir) &&
@@ -586,10 +586,15 @@ describe("NewWorkspaceScreen picker payload", () => {
expect(pickerRow.style.transform).toBe("translateY(-216px)");
});
it("searches only GitHub PRs for the picker", async () => {
it("lazily searches only GitHub PRs when the picker opens", async () => {
renderScreen();
await flush();
expect(mockClient.searchGitHub).not.toHaveBeenCalled();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
expect(mockClient.searchGitHub).toHaveBeenCalledWith({
cwd: "/repo",
query: "",
@@ -183,7 +183,7 @@ export function NewWorkspaceScreen({
limit: 20,
});
},
enabled: isConnected && !!client,
enabled: pickerOpen && isConnected && !!client,
staleTime: 15_000,
});
@@ -198,7 +198,7 @@ export function NewWorkspaceScreen({
kinds: ["github-pr"],
});
},
enabled: isConnected && !!client,
enabled: pickerOpen && isConnected && !!client,
staleTime: 30_000,
});
@@ -55,4 +55,15 @@ describe("assistant image metadata", () => {
),
).toBeGreaterThan(220);
});
it("estimates image-only data-image markdown without caching the full payload as text", () => {
const source = `data:image/png;base64,${"a".repeat(512)}`;
setAssistantImageMetadata({ source }, { width: 1200, height: 800 });
const imageOnlyHeight = estimateAssistantMessageHeightFromCache(`![Screenshot](${source})`);
const mixedHeight = estimateAssistantMessageHeightFromCache(`Text\n\n![Screenshot](${source})`);
expect(imageOnlyHeight).toBeGreaterThan(220);
expect(mixedHeight).toBeGreaterThan(imageOnlyHeight ?? 0);
});
});
@@ -1,5 +1,6 @@
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import { createImageSourceCacheKey } from "@/attachments/utils";
export interface AssistantImageMetadata {
width: number;
@@ -48,8 +49,25 @@ function normalizeAssistantImageSourceToken(value: string): string | null {
return source || null;
}
function parseAssistantImageMarkdown(markdown: string): {
sources: string[];
hasNonImageText: boolean;
} {
const sources: string[] = [];
for (const match of markdown.matchAll(MARKDOWN_IMAGE_PATTERN)) {
const normalized = normalizeAssistantImageSourceToken(match[1] ?? "");
if (normalized) {
sources.push(normalized);
}
}
return {
sources,
hasNonImageText: markdown.replace(MARKDOWN_IMAGE_PATTERN, "").trim().length > 0,
};
}
function createSourceAliasKey(source: string): string {
return `source:${source}`;
return `source:${createImageSourceCacheKey(source)}`;
}
function createResolutionKey(input: {
@@ -65,7 +83,7 @@ function createResolutionKey(input: {
return null;
}
if (resolution.kind === "direct") {
return `direct:${resolution.uri}`;
return `direct:${createImageSourceCacheKey(resolution.uri)}`;
}
return `file:${input.serverId ?? "unknown-server"}:${resolution.cwd}:${resolution.path}`;
}
@@ -140,7 +158,8 @@ export function setAssistantImageMetadata(
}
export function extractAssistantImageSources(markdown: string): string[] {
const cachedParse = assistantImageParseCache.get(markdown);
const shouldCacheParse = !/data:image\//i.test(markdown);
const cachedParse = shouldCacheParse ? assistantImageParseCache.get(markdown) : undefined;
if (cachedParse) {
touchCacheEntry(
assistantImageParseCache,
@@ -151,32 +170,15 @@ export function extractAssistantImageSources(markdown: string): string[] {
return cachedParse.sources;
}
const sources: string[] = [];
for (const match of markdown.matchAll(MARKDOWN_IMAGE_PATTERN)) {
const normalized = normalizeAssistantImageSourceToken(match[1] ?? "");
if (normalized) {
sources.push(normalized);
}
const parsed = parseAssistantImageMarkdown(markdown);
if (shouldCacheParse) {
touchCacheEntry(assistantImageParseCache, markdown, parsed, ASSISTANT_IMAGE_PARSE_CACHE_LIMIT);
}
const hasNonImageText = markdown.replace(MARKDOWN_IMAGE_PATTERN, "").trim().length > 0;
touchCacheEntry(
assistantImageParseCache,
markdown,
{ sources, hasNonImageText },
ASSISTANT_IMAGE_PARSE_CACHE_LIMIT,
);
return sources;
return parsed.sources;
}
export function estimateAssistantMessageHeightFromCache(markdown: string): number | null {
const cachedParse = assistantImageParseCache.get(markdown);
const parsed =
cachedParse ??
(() => {
const sources = extractAssistantImageSources(markdown);
const nextParsed = assistantImageParseCache.get(markdown);
return nextParsed ?? { sources, hasNonImageText: true };
})();
const parsed = assistantImageParseCache.get(markdown) ?? parseAssistantImageMarkdown(markdown);
if (parsed.sources.length === 0) {
return null;
}
@@ -28,8 +28,10 @@ function createTestStore(): AttachmentStore {
byteSize = input.source.blob.size;
} else if (input.source.kind === "data_url") {
byteSize = input.source.dataUrl.length;
} else {
} else if (input.source.kind === "file_uri") {
byteSize = input.source.uri.length;
} else {
byteSize = input.source.base64.length;
}
return {
id,
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.60-beta.2",
"@getpaseo/server": "0.1.60-beta.2",
"@getpaseo/relay": "0.1.60",
"@getpaseo/server": "0.1.60",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -13,8 +13,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.60-beta.2",
"@getpaseo/server": "0.1.60-beta.2",
"@getpaseo/cli": "0.1.60",
"@getpaseo/server": "0.1.60",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"type": "module",
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -60,8 +60,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.60-beta.2",
"@getpaseo/relay": "0.1.60-beta.2",
"@getpaseo/highlight": "0.1.60",
"@getpaseo/relay": "0.1.60",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-coding-agent": "^0.67.68",
"@modelcontextprotocol/sdk": "^1.20.1",
@@ -652,12 +652,19 @@ describe("create_agent MCP tool", () => {
const workspaceGitService = {
getSnapshot: vi.fn(async () => null),
};
const archiveWorkspaceRecord = vi.fn(async () => undefined);
const emitWorkspaceUpdatesForCwds = vi.fn(async () => undefined);
const emitSessionMessage = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }),
workspaceGitService: workspaceGitService as any,
archiveWorkspaceRecord,
emitWorkspaceUpdatesForCwds,
emitSessionMessage,
github: createGitHubServiceStub(),
logger,
});
const createTool = (server as any)._registeredTools["create_worktree"];
@@ -678,6 +685,10 @@ describe("create_agent MCP tool", () => {
force: true,
reason: "archive-worktree",
});
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(created.structuredContent.worktreePath);
expect(Array.from(emitWorkspaceUpdatesForCwds.mock.calls[0]?.[0] ?? [])).toEqual([
created.structuredContent.worktreePath,
]);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
+61 -18
View File
@@ -26,7 +26,12 @@ import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "./timeline-append.js";
import { deletePaseoWorktree, type WorktreeConfig } from "../../utils/worktree.js";
import { getPaseoWorktreesRoot, type WorktreeConfig } from "../../utils/worktree.js";
import {
archivePaseoWorktree,
killTerminalsUnderPath,
type ArchivePaseoWorktreeDependencies,
} from "../paseo-worktree-archive-service.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
@@ -61,6 +66,7 @@ import type {
CreatePaseoWorktreeResult,
} from "../paseo-worktree-service.js";
import { toWorktreeRequestError } from "../worktree-errors.js";
import { join } from "node:path";
export interface AgentMcpServerOptions {
agentManager: AgentManager;
@@ -71,6 +77,9 @@ export interface AgentMcpServerOptions {
providerRegistry?: Record<AgentProvider, ProviderDefinition> | null;
github?: GitHubService;
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"];
emitWorkspaceUpdatesForCwds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForCwds"];
emitSessionMessage?: ArchivePaseoWorktreeDependencies["emit"];
createPaseoWorktree?: CreatePaseoWorktreeFn;
paseoHome?: string;
/**
@@ -1733,24 +1742,58 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
async ({ cwd, worktreePath, worktreeSlug }) => {
const repoRoot = resolveScopedCwd(cwd, { required: true });
await deletePaseoWorktree({
cwd: repoRoot,
worktreePath,
worktreeSlug,
paseoHome: options.paseoHome,
});
options.github?.invalidate({ cwd: repoRoot });
try {
await options.workspaceGitService?.getSnapshot(repoRoot, {
force: true,
reason: "archive-worktree",
});
} catch (error) {
options.logger.warn(
{ err: error, cwd: repoRoot },
"Failed to force-refresh workspace git snapshot after archiving worktree",
);
if (!worktreePath && !worktreeSlug) {
throw new Error("worktreePath or worktreeSlug is required");
}
if (!options.github) {
throw new Error("GitHub service is required to archive worktrees");
}
if (!options.workspaceGitService) {
throw new Error("WorkspaceGitService is required to archive worktrees");
}
if (!options.archiveWorkspaceRecord) {
throw new Error("Workspace registry archiver is required to archive worktrees");
}
if (!options.emitWorkspaceUpdatesForCwds) {
throw new Error("Workspace update emitter is required to archive worktrees");
}
if (!options.emitSessionMessage) {
throw new Error("Session message emitter is required to archive worktrees");
}
const targetPath =
worktreePath ??
join(await getPaseoWorktreesRoot(repoRoot, options.paseoHome), worktreeSlug!);
await archivePaseoWorktree(
{
paseoHome: options.paseoHome,
github: options.github,
workspaceGitService: options.workspaceGitService,
agentManager,
agentStorage,
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
emit: options.emitSessionMessage,
emitWorkspaceUpdatesForCwds: options.emitWorkspaceUpdatesForCwds,
isPathWithinRoot: isSameOrDescendantPath,
killTerminalsUnderPath: (rootPath) =>
killTerminalsUnderPath(
{
terminalManager: terminalManager ?? null,
isPathWithinRoot: isSameOrDescendantPath,
killTrackedTerminal: () => {},
sessionLogger: childLogger,
},
rootPath,
),
sessionLogger: childLogger,
},
{
targetPath,
repoRoot,
requestId: "mcp:archive_worktree",
},
);
return {
content: [],
+33
View File
@@ -112,6 +112,8 @@ import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore } from "./daemon-config-store.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
@@ -493,6 +495,34 @@ export async function createPaseoDaemon(
if (mcpEnabled) {
const agentMcpRoute = "/mcp/agents";
const agentMcpTransports: AgentMcpTransportMap = new Map();
const archiveWorkspaceRecordForMcp = async (workspaceId: string) => {
const sessions = wsServer?.listActiveSessions() ?? [];
if (sessions.length > 0) {
await Promise.all(
sessions.map((session) =>
session.archiveWorkspaceRecordForExternalMutation(workspaceId),
),
);
return;
}
await archivePersistedWorkspaceRecord({
workspaceId,
workspaceRegistry,
projectRegistry,
});
};
const emitWorkspaceUpdatesForMcpArchive = async (cwds: Iterable<string>) => {
const cwdList = Array.from(cwds);
await Promise.all(
(wsServer?.listActiveSessions() ?? []).map((session) =>
session.emitWorkspaceUpdatesForExternalCwds(cwdList),
),
);
};
const emitMcpArchiveSessionMessage = (message: SessionOutboundMessage) => {
wsServer?.broadcast(wrapSessionMessage(message));
};
const createAgentMcpTransport = async (callerAgentId?: string) => {
const agentMcpServer = await createAgentMcpServer({
@@ -505,6 +535,9 @@ export async function createPaseoDaemon(
providerRegistry,
github,
workspaceGitService,
archiveWorkspaceRecord: archiveWorkspaceRecordForMcp,
emitWorkspaceUpdatesForCwds: emitWorkspaceUpdatesForMcpArchive,
emitSessionMessage: emitMcpArchiveSessionMessage,
createPaseoWorktree: async (input, serviceOptions) => {
const coreDeps = createWorktreeCoreDeps(github);
const result = await createPaseoWorktree(input, {
@@ -0,0 +1,304 @@
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createMessageCollector } from "../test-utils/message-collector.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import type { SessionOutboundMessage } from "../messages.js";
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-codex-tool-interrupt-"));
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function generateClientMessageId(): string {
return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
}
function getAgentStatuses(messages: SessionOutboundMessage[], agentId: string): string[] {
return messages
.filter(
(message) =>
message.type === "agent_update" &&
message.payload.kind === "upsert" &&
message.payload.agent.id === agentId,
)
.map((message) => message.payload.agent.status);
}
function getAgentStatusesBeforeFirstAssistant(
messages: SessionOutboundMessage[],
agentId: string,
): string[] {
const firstAssistantIndex = messages.findIndex(
(message) =>
message.type === "agent_stream" &&
message.payload.agentId === agentId &&
message.payload.event.type === "timeline" &&
message.payload.event.item.type === "assistant_message",
);
const observedPrefix =
firstAssistantIndex < 0 ? messages : messages.slice(0, firstAssistantIndex);
return getAgentStatuses(observedPrefix, agentId);
}
function getAssistantTexts(messages: SessionOutboundMessage[], agentId: string): string[] {
return messages
.filter(
(message) =>
message.type === "agent_stream" &&
message.payload.agentId === agentId &&
message.payload.event.type === "timeline" &&
message.payload.event.item.type === "assistant_message",
)
.map((message) => message.payload.event.item.text);
}
function hasProviderLimitText(text: string): boolean {
return /hit your limit|rate limit|quota|credits/i.test(text);
}
function assertNoProviderLimit(timeline: Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>) {
const assistantTexts = timeline.entries
.filter((entry) => entry.item.type === "assistant_message")
.slice(-5)
.map((entry) => entry.item.text);
const limitText = assistantTexts.find((text) => hasProviderLimitText(text));
if (limitText) {
throw new Error(`Codex provider rejected the run: ${limitText}`);
}
}
function summarizeTimelineItems(timeline: Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>) {
return timeline.entries.slice(-15).map((entry) => {
const item = entry.item;
if (item.type === "assistant_message") {
return { type: item.type, text: item.text };
}
if (item.type === "tool_call") {
return {
type: item.type,
name: item.name,
status: item.status,
callId: item.callId,
};
}
if (item.type === "user_message") {
return { type: item.type, text: item.text };
}
return { type: item.type };
});
}
async function waitForAssistantWaitingOnSleep(
client: DaemonClient,
agentId: string,
timeoutMs = 75_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const timeline = await client.fetchAgentTimeline(agentId, { limit: 100 });
assertNoProviderLimit(timeline);
const assistantText = timeline.entries
.filter((entry) => entry.item.type === "assistant_message")
.map((entry) => entry.item.text)
.join("\n");
if (/still running|waiting/i.test(assistantText) && !/`done`|\bdone\b/i.test(assistantText)) {
return;
}
await sleep(500);
}
const timeline = await client.fetchAgentTimeline(agentId, { limit: 100 }).catch(() => null);
const recentToolCalls =
timeline?.entries
.filter((entry) => entry.item.type === "tool_call")
.slice(-10)
.map((entry) => ({
name: entry.item.name,
status: entry.item.status,
callId: entry.item.callId,
})) ?? [];
const recentAssistantTexts =
timeline?.entries
.filter((entry) => entry.item.type === "assistant_message")
.slice(-5)
.map((entry) => entry.item.text) ?? [];
throw new Error(
`Timed out waiting for Codex to report it was waiting on sleep. Recent tool_calls=${JSON.stringify(recentToolCalls)} recent assistant text=${JSON.stringify(recentAssistantTexts)}`,
);
}
describe("daemon E2E (real codex) - send message during tool call", () => {
let canRun = false;
beforeAll(async () => {
canRun = await isProviderAvailable("codex");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("does not emit an idle agent_update between UI send and the replacement Codex turn", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { codex: new CodexAppServerAgentClient(logger) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
let collector: ReturnType<typeof createMessageCollector> | null = null;
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "primary" } });
const agent = await client.createAgent({
cwd,
title: "codex-tool-interrupt-repro",
...getFullAccessConfig("codex"),
});
collector = createMessageCollector(client);
await client.sendMessage(
agent.id,
"Run `sleep 60 && echo done` and tell me what it outputs. Be brief.",
);
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
60_000,
);
await waitForAssistantWaitingOnSleep(client, agent.id);
collector.clear();
await client.sendAgentMessage(agent.id, "Reply with exactly: INTERRUPT_RECEIVED", {
messageId: generateClientMessageId(),
});
const finish = await client.waitForFinish(agent.id, 120_000);
const postSendMessages = [...collector.messages];
const postSendStatuses = getAgentStatuses(postSendMessages, agent.id);
const statusesBeforeFirstAssistant = getAgentStatusesBeforeFirstAssistant(
postSendMessages,
agent.id,
);
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
if (finish.status !== "idle") {
const snapshot = await client.fetchAgent(agent.id);
throw new Error(
`Expected idle after replacement, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(getAssistantTexts(postSendMessages, agent.id))} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`,
);
}
expect(statusesBeforeFirstAssistant).not.toContain("idle");
expect(statusesBeforeFirstAssistant).not.toContain("error");
const assistantTexts = timeline.entries
.filter((entry) => entry.item.type === "assistant_message")
.map((entry) => {
const item = entry.item as Extract<AgentTimelineItem, { type: "assistant_message" }>;
return item.text;
});
expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
expect(assistantTexts.some((text) => text.toUpperCase().includes("INTERRUPT_RECEIVED"))).toBe(
true,
);
} finally {
collector?.unsubscribe();
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 300_000);
test("does not emit an idle agent_update when a second prompt is sent 200ms after the first", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { codex: new CodexAppServerAgentClient(logger) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
let collector: ReturnType<typeof createMessageCollector> | null = null;
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "primary" } });
const agent = await client.createAgent({
cwd,
title: "codex-quick-follow-up-repro",
...getFullAccessConfig("codex"),
});
collector = createMessageCollector(client);
await client.sendAgentMessage(
agent.id,
"Run `sleep 60 && echo done` and tell me what it outputs. Be brief.",
{
messageId: generateClientMessageId(),
},
);
await sleep(200);
collector.clear();
await client.sendAgentMessage(agent.id, "Reply with exactly: QUICK_FOLLOW_UP_RECEIVED", {
messageId: generateClientMessageId(),
});
const finish = await client.waitForFinish(agent.id, 120_000);
const postSendMessages = [...collector.messages];
const postSendStatuses = getAgentStatuses(postSendMessages, agent.id);
const statusesBeforeFirstAssistant = getAgentStatusesBeforeFirstAssistant(
postSendMessages,
agent.id,
);
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
if (finish.status !== "idle") {
const snapshot = await client.fetchAgent(agent.id);
throw new Error(
`Expected idle after quick follow-up, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(getAssistantTexts(postSendMessages, agent.id))} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`,
);
}
expect(statusesBeforeFirstAssistant).not.toContain("idle");
expect(statusesBeforeFirstAssistant).not.toContain("error");
const assistantTexts = timeline.entries
.filter((entry) => entry.item.type === "assistant_message")
.map((entry) => {
const item = entry.item as Extract<AgentTimelineItem, { type: "assistant_message" }>;
return item.text;
});
expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
expect(
assistantTexts.some((text) => text.toUpperCase().includes("QUICK_FOLLOW_UP_RECEIVED")),
).toBe(true);
} finally {
collector?.unsubscribe();
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 300_000);
});
@@ -0,0 +1,220 @@
import type { Logger } from "pino";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type { SessionOutboundMessage } from "./messages.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
import type { GitHubService } from "../services/github-service.js";
import { deletePaseoWorktree, resolvePaseoWorktreeRootForCwd } from "../utils/worktree.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
type EmitSessionMessage = (message: SessionOutboundMessage) => void;
export type ArchivePaseoWorktreeDependencies = {
paseoHome?: string;
github: GitHubService;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
agentManager: Pick<AgentManager, "listAgents" | "closeAgent">;
agentStorage: Pick<AgentStorage, "list" | "remove">;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emit: EmitSessionMessage;
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
sessionLogger?: Logger;
};
export type KillTerminalsUnderPathDependencies = {
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTrackedTerminal: (terminalId: string, options?: { emitExit: boolean }) => void;
detachTerminalStream?: (terminalId: string, options: { emitExit: boolean }) => void;
sessionLogger: Logger;
terminalManager: TerminalManager | null;
};
export async function archivePaseoWorktree(
dependencies: ArchivePaseoWorktreeDependencies,
options: {
targetPath: string;
repoRoot: string | null;
worktreesRoot?: string;
requestId: string;
},
): Promise<string[]> {
let targetPath = options.targetPath;
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
paseoHome: dependencies.paseoHome,
});
if (resolvedWorktree) {
targetPath = resolvedWorktree.worktreePath;
}
const removedAgents = new Set<string>();
const affectedWorkspaceCwds = new Set<string>([targetPath]);
const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)]);
const liveAgents = dependencies.agentManager
.listAgents()
.filter((agent) => dependencies.isPathWithinRoot(targetPath, agent.cwd));
for (const agent of liveAgents) {
removedAgents.add(agent.id);
affectedWorkspaceCwds.add(agent.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd));
}
let storedRecords: StoredAgentRecord[] = [];
try {
storedRecords = await dependencies.agentStorage.list();
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, targetPath },
"Failed to list stored agents during worktree archive; continuing",
);
}
const matchingStoredRecords = storedRecords.filter((record) =>
dependencies.isPathWithinRoot(targetPath, record.cwd),
);
for (const record of matchingStoredRecords) {
removedAgents.add(record.id);
affectedWorkspaceCwds.add(record.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd));
}
const agentIdsToRemoveFromStorage = new Set<string>([
...liveAgents.map((agent) => agent.id),
...matchingStoredRecords.map((record) => record.id),
]);
const teardownResults = await Promise.allSettled([
...liveAgents.map((agent) => dependencies.agentManager.closeAgent(agent.id)),
dependencies.killTerminalsUnderPath(targetPath),
]);
for (const result of teardownResults) {
if (result.status === "rejected") {
dependencies.sessionLogger?.warn(
{ err: result.reason, targetPath },
"Worktree teardown step failed during archive; continuing",
);
}
}
const agentIdsToRemove = Array.from(agentIdsToRemoveFromStorage);
const storageRemovalResults = await Promise.allSettled(
agentIdsToRemove.map((agentId) => dependencies.agentStorage.remove(agentId)),
);
storageRemovalResults.forEach((result, index) => {
if (result.status === "fulfilled") {
return;
}
dependencies.sessionLogger?.warn(
{
err: result.reason,
agentId: agentIdsToRemove[index],
targetPath,
},
"Failed to remove archived worktree agent from storage; continuing",
);
});
await deletePaseoWorktree({
cwd: options.repoRoot,
worktreePath: targetPath,
worktreesRoot: options.worktreesRoot,
paseoHome: dependencies.paseoHome,
});
if (options.repoRoot) {
try {
await dependencies.workspaceGitService.getSnapshot(options.repoRoot, {
force: true,
reason: "archive-worktree",
});
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, cwd: options.repoRoot },
"Failed to force-refresh workspace git snapshot after archiving worktree",
);
}
}
for (const cwd of affectedWorkspaceCwds) {
dependencies.github.invalidate({ cwd });
}
for (const workspaceId of affectedWorkspaceIds) {
try {
await dependencies.archiveWorkspaceRecord(workspaceId);
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, workspaceId },
"Failed to archive workspace record; worktree FS already removed",
);
}
}
for (const agentId of removedAgents) {
dependencies.emit({
type: "agent_deleted",
payload: {
agentId,
requestId: options.requestId,
},
});
}
await dependencies.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds);
return Array.from(removedAgents);
}
export async function killTerminalsUnderPath(
dependencies: KillTerminalsUnderPathDependencies,
rootPath: string,
): Promise<void> {
const terminalManager = dependencies.terminalManager;
if (!terminalManager) {
return;
}
const terminalIds: string[] = [];
const terminalDirectories = [...terminalManager.listDirectories()];
for (const terminalCwd of terminalDirectories) {
if (!dependencies.isPathWithinRoot(rootPath, terminalCwd)) {
continue;
}
try {
const terminals = await terminalManager.getTerminals(terminalCwd);
for (const terminal of terminals) {
terminalIds.push(terminal.id);
}
} catch (error) {
dependencies.sessionLogger.warn(
{ err: error, cwd: terminalCwd },
"Failed to enumerate worktree terminals during archive",
);
}
}
if (terminalIds.length === 0) {
return;
}
await Promise.allSettled(
terminalIds.map(async (terminalId) => {
try {
dependencies.detachTerminalStream?.(terminalId, { emitExit: true });
await terminalManager.killTerminalAndWait(terminalId, {
gracefulTimeoutMs: 2000,
forceTimeoutMs: 1500,
});
} catch (error) {
dependencies.sessionLogger.warn(
{ err: error, terminalId },
"Terminal kill escalation failed during archive; proceeding anyway",
);
}
}),
);
}
+17 -12
View File
@@ -165,6 +165,7 @@ import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
import { type WorktreeConfig } from "../utils/worktree.js";
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import {
@@ -211,8 +212,8 @@ import {
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
killTerminalsUnderPath as killWorktreeTerminalsUnderPath,
} from "./worktree-session.js";
import { killTerminalsUnderPath as killWorktreeTerminalsUnderPath } from "./paseo-worktree-archive-service.js";
import { toWorktreeWireError } from "./worktree-errors.js";
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
@@ -907,6 +908,14 @@ export class Session {
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true });
}
async archiveWorkspaceRecordForExternalMutation(workspaceId: string): Promise<void> {
await this.archiveWorkspaceRecord(workspaceId);
}
async emitWorkspaceUpdatesForExternalCwds(cwds: Iterable<string>): Promise<void> {
await this.emitWorkspaceUpdatesForCwds(cwds);
}
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
await this.primeWorkspaceGitWatchFingerprintForWorkspace(workspace);
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
@@ -6403,24 +6412,20 @@ export class Session {
}
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
const existingWorkspace = await this.workspaceRegistry.get(workspaceId);
if (!existingWorkspace || existingWorkspace.archivedAt) {
const existingWorkspace = await archivePersistedWorkspaceRecord({
workspaceId,
archivedAt,
workspaceRegistry: this.workspaceRegistry,
projectRegistry: this.projectRegistry,
});
if (!existingWorkspace) {
this.removeWorkspaceGitSubscription(workspaceId);
return;
}
const nextArchivedAt = archivedAt ?? new Date().toISOString();
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt);
await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd);
this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd);
this.removeWorkspaceGitSubscription(workspaceId);
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
);
if (siblingWorkspaces.length === 0) {
await this.projectRegistry.archive(existingWorkspace.projectId, nextArchivedAt);
}
}
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
@@ -0,0 +1,32 @@
import type {
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
export async function archivePersistedWorkspaceRecord(input: {
workspaceId: string;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "archive">;
projectRegistry: Pick<ProjectRegistry, "archive">;
archivedAt?: string;
}): Promise<PersistedWorkspaceRecord | null> {
const existingWorkspace = await input.workspaceRegistry.get(input.workspaceId);
if (!existingWorkspace) {
return null;
}
if (existingWorkspace.archivedAt) {
return existingWorkspace;
}
const archivedAt = input.archivedAt ?? new Date().toISOString();
await input.workspaceRegistry.archive(input.workspaceId, archivedAt);
const activeSiblings = (await input.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
);
if (activeSiblings.length === 0) {
await input.projectRegistry.archive(existingWorkspace.projectId, archivedAt);
}
return existingWorkspace;
}
@@ -14,8 +14,8 @@ import pino, { type Logger } from "pino";
import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messages.js";
import { ScriptRouteStore } from "./script-proxy.js";
import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js";
import {
archivePaseoWorktree,
buildAgentSessionConfig,
handlePaseoWorktreeArchiveRequest,
handlePaseoWorktreeListRequest,
+12 -220
View File
@@ -2,8 +2,6 @@ import type { Logger } from "pino";
import { basename } from "node:path";
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import {
type AgentAttachment,
type GitSetupOptions,
@@ -14,7 +12,6 @@ import {
} from "./messages.js";
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
import {
applyWorktreeSetupProgressEvent,
buildWorktreeSetupDetail,
@@ -28,10 +25,8 @@ import type { GitHubService } from "../services/github-service.js";
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import {
deletePaseoWorktree,
getWorktreeSetupCommands,
isPaseoOwnedWorktreeCwd,
resolvePaseoWorktreeRootForCwd,
resolveWorktreeRuntimeEnv,
runWorktreeSetupCommands,
slugify,
@@ -45,6 +40,10 @@ import type {
CreatePaseoWorktreeInput,
CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js";
import {
archivePaseoWorktree,
type ArchivePaseoWorktreeDependencies,
} from "./paseo-worktree-archive-service.js";
import { toWorktreeWireError } from "./worktree-errors.js";
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
@@ -82,20 +81,6 @@ type BuildAgentSessionConfigDependencies = {
github?: Pick<GitHubService, "invalidate">;
};
type ArchivePaseoWorktreeDependencies = {
paseoHome?: string;
github: GitHubService;
workspaceGitService: WorkspaceGitService;
agentManager: Pick<AgentManager, "listAgents" | "closeAgent">;
agentStorage: Pick<AgentStorage, "list" | "remove">;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emit: EmitSessionMessage;
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
sessionLogger?: Logger;
};
type CreatePaseoWorktreeInBackgroundDependencies = {
paseoHome?: string;
emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise<void>;
@@ -136,14 +121,6 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
}) => Promise<void>;
};
type KillTerminalsUnderPathDependencies = {
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTrackedTerminal: (terminalId: string, options?: { emitExit: boolean }) => void;
detachTerminalStream?: (terminalId: string, options: { emitExit: boolean }) => void;
sessionLogger: Logger;
terminalManager: TerminalManager | null;
};
export async function buildAgentSessionConfig(
dependencies: BuildAgentSessionConfigDependencies,
config: AgentSessionConfig,
@@ -368,151 +345,13 @@ export async function handlePaseoWorktreeListRequest(
}
}
export async function archivePaseoWorktree(
dependencies: ArchivePaseoWorktreeDependencies,
options: {
targetPath: string;
repoRoot: string | null;
worktreesRoot?: string;
requestId: string;
},
): Promise<string[]> {
let targetPath = options.targetPath;
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
paseoHome: dependencies.paseoHome,
});
if (resolvedWorktree) {
targetPath = resolvedWorktree.worktreePath;
}
const removedAgents = new Set<string>();
const affectedWorkspaceCwds = new Set<string>([targetPath]);
const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)]);
const liveAgents = dependencies.agentManager
.listAgents()
.filter((agent) => dependencies.isPathWithinRoot(targetPath, agent.cwd));
for (const agent of liveAgents) {
removedAgents.add(agent.id);
affectedWorkspaceCwds.add(agent.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd));
}
let storedRecords: StoredAgentRecord[] = [];
try {
storedRecords = await dependencies.agentStorage.list();
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, targetPath },
"Failed to list stored agents during worktree archive; continuing",
);
}
const matchingStoredRecords = storedRecords.filter((record) =>
dependencies.isPathWithinRoot(targetPath, record.cwd),
);
for (const record of matchingStoredRecords) {
removedAgents.add(record.id);
affectedWorkspaceCwds.add(record.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd));
}
const agentIdsToRemoveFromStorage = new Set<string>([
...liveAgents.map((agent) => agent.id),
...matchingStoredRecords.map((record) => record.id),
]);
// Fan out agent close + terminal teardown concurrently. We never let a
// per-item failure abort the archive — the FS delete must still run so the
// worktree doesn't get stuck half-dead.
const teardownResults = await Promise.allSettled([
...liveAgents.map((agent) => dependencies.agentManager.closeAgent(agent.id)),
dependencies.killTerminalsUnderPath(targetPath),
]);
for (const result of teardownResults) {
if (result.status === "rejected") {
dependencies.sessionLogger?.warn(
{ err: result.reason, targetPath },
"Worktree teardown step failed during archive; continuing",
);
}
}
// Agent storage removal runs after closeAgent so file handles on the agent
// state file are released; still allSettled so a single bad record can't
// derail the rest.
const agentIdsToRemove = Array.from(agentIdsToRemoveFromStorage);
const storageRemovalResults = await Promise.allSettled(
agentIdsToRemove.map((agentId) => dependencies.agentStorage.remove(agentId)),
);
storageRemovalResults.forEach((result, index) => {
if (result.status === "fulfilled") {
return;
}
dependencies.sessionLogger?.warn(
{
err: result.reason,
agentId: agentIdsToRemove[index],
targetPath,
},
"Failed to remove archived worktree agent from storage; continuing",
);
});
await deletePaseoWorktree({
cwd: options.repoRoot,
worktreePath: targetPath,
worktreesRoot: options.worktreesRoot,
paseoHome: dependencies.paseoHome,
});
if (options.repoRoot) {
try {
await dependencies.workspaceGitService.getSnapshot(options.repoRoot, {
force: true,
reason: "archive-worktree",
});
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, cwd: options.repoRoot },
"Failed to force-refresh workspace git snapshot after archiving worktree",
);
}
}
for (const cwd of affectedWorkspaceCwds) {
dependencies.github.invalidate({ cwd });
}
for (const workspaceId of affectedWorkspaceIds) {
try {
await dependencies.archiveWorkspaceRecord(workspaceId);
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, workspaceId },
"Failed to archive workspace record; worktree FS already removed",
);
}
}
for (const agentId of removedAgents) {
dependencies.emit({
type: "agent_deleted",
payload: {
agentId,
requestId: options.requestId,
},
});
}
await dependencies.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds);
return Array.from(removedAgents);
}
export async function handlePaseoWorktreeArchiveRequest(
dependencies: Omit<ArchivePaseoWorktreeDependencies, "emitWorkspaceUpdatesForCwds"> & {
dependencies: Omit<
ArchivePaseoWorktreeDependencies,
"emitWorkspaceUpdatesForCwds" | "workspaceGitService"
> & {
emit: EmitSessionMessage;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
},
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
@@ -533,6 +372,9 @@ export async function handlePaseoWorktreeArchiveRequest(
}
targetPath = match.path;
}
if (!targetPath) {
throw new Error("worktreePath could not be resolved");
}
const ownership = await isPaseoOwnedWorktreeCwd(targetPath, {
paseoHome: dependencies.paseoHome,
@@ -779,53 +621,3 @@ export async function runWorktreeSetupInBackground(
await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath);
}
}
export async function killTerminalsUnderPath(
dependencies: KillTerminalsUnderPathDependencies,
rootPath: string,
): Promise<void> {
const terminalManager = dependencies.terminalManager;
if (!terminalManager) {
return;
}
const terminalIds: string[] = [];
const terminalDirectories = [...terminalManager.listDirectories()];
for (const terminalCwd of terminalDirectories) {
if (!dependencies.isPathWithinRoot(rootPath, terminalCwd)) {
continue;
}
try {
const terminals = await terminalManager.getTerminals(terminalCwd);
for (const terminal of terminals) {
terminalIds.push(terminal.id);
}
} catch (error) {
dependencies.sessionLogger.warn(
{ err: error, cwd: terminalCwd },
"Failed to enumerate worktree terminals during archive",
);
}
}
if (terminalIds.length === 0) {
return;
}
await Promise.allSettled(
terminalIds.map(async (terminalId) => {
try {
dependencies.detachTerminalStream?.(terminalId, { emitExit: true });
await terminalManager.killTerminalAndWait(terminalId, {
gracefulTimeoutMs: 2000,
forceTimeoutMs: 1500,
});
} catch (error) {
dependencies.sessionLogger.warn(
{ err: error, terminalId },
"Terminal kill escalation failed during archive; proceeding anyway",
);
}
}),
);
}
@@ -46,6 +46,69 @@ describe("shared messages stream parsing", () => {
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message");
});
it("parses legacy worktree setup timeline entries without per-command log", () => {
const parsed = FetchAgentTimelineResponseMessageSchema.parse({
type: "fetch_agent_timeline_response",
payload: {
requestId: "req-legacy-setup",
agentId: "agent_legacy_setup",
agent: null,
direction: "tail",
projection: "canonical",
epoch: "epoch-setup",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
startCursor: { epoch: "epoch-setup", seq: 1 },
endCursor: { epoch: "epoch-setup", seq: 1 },
hasOlder: false,
hasNewer: false,
entries: [
{
provider: "codex",
item: {
type: "tool_call",
callId: "setup-1",
name: "paseo_worktree_setup",
status: "completed",
detail: {
type: "worktree_setup",
worktreePath: "/repo/.paseo/worktrees/feature",
branchName: "feature",
log: "setup complete",
commands: [
{
index: 1,
command: "npm install",
cwd: "/repo/.paseo/worktrees/feature",
status: "completed",
exitCode: 0,
durationMs: 100,
},
],
},
error: null,
},
timestamp: "2026-04-22T00:00:00.000Z",
seqStart: 1,
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
collapsed: [],
},
],
error: null,
},
});
const item = parsed.payload.entries[0]?.item;
expect(item?.type).toBe("tool_call");
if (item?.type !== "tool_call" || item.detail.type !== "worktree_setup") {
throw new Error("Expected worktree setup tool call");
}
expect(item.detail.commands[0]?.log).toBe("");
});
it("parses explicit shutdown and restart lifecycle request payloads as distinct message types", () => {
const shutdownParsed = SessionInboundMessageSchema.safeParse({
type: "shutdown_server_request",
+122 -112
View File
@@ -251,7 +251,11 @@ export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
}),
]);
export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionRequest> = z.object({
export const AgentPermissionRequestPayloadSchema: z.ZodType<
AgentPermissionRequest,
z.ZodTypeDef,
unknown
> = z.object({
id: z.string(),
provider: AgentProviderSchema,
name: z.string(),
@@ -286,7 +290,7 @@ const WorktreeSetupCommandSnapshotSchema = z.object({
index: z.number().int().positive(),
command: z.string(),
cwd: z.string(),
log: z.string(),
log: z.string().optional().default(""),
status: z.enum(["running", "completed", "failed"]),
exitCode: z.number().nullable(),
durationMs: z.number().nonnegative().optional(),
@@ -301,95 +305,96 @@ const WorktreeSetupDetailPayloadSchema = z.object({
truncated: z.boolean().optional(),
});
const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUnion("type", [
WorktreeSetupDetailPayloadSchema,
z.object({
type: z.literal("shell"),
command: z.string(),
cwd: z.string().optional(),
output: z.string().optional(),
exitCode: z.number().nullable().optional(),
}),
z.object({
type: z.literal("read"),
filePath: z.string(),
content: z.string().optional(),
offset: z.number().optional(),
limit: z.number().optional(),
}),
z.object({
type: z.literal("edit"),
filePath: z.string(),
oldString: z.string().optional(),
newString: z.string().optional(),
unifiedDiff: z.string().optional(),
}),
z.object({
type: z.literal("write"),
filePath: z.string(),
content: z.string().optional(),
}),
z.object({
type: z.literal("search"),
query: z.string(),
toolName: z.enum(["search", "grep", "glob", "web_search"]).optional(),
content: z.string().optional(),
filePaths: z.array(z.string()).optional(),
webResults: z
.array(
const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail, z.ZodTypeDef, unknown> =
z.discriminatedUnion("type", [
WorktreeSetupDetailPayloadSchema,
z.object({
type: z.literal("shell"),
command: z.string(),
cwd: z.string().optional(),
output: z.string().optional(),
exitCode: z.number().nullable().optional(),
}),
z.object({
type: z.literal("read"),
filePath: z.string(),
content: z.string().optional(),
offset: z.number().optional(),
limit: z.number().optional(),
}),
z.object({
type: z.literal("edit"),
filePath: z.string(),
oldString: z.string().optional(),
newString: z.string().optional(),
unifiedDiff: z.string().optional(),
}),
z.object({
type: z.literal("write"),
filePath: z.string(),
content: z.string().optional(),
}),
z.object({
type: z.literal("search"),
query: z.string(),
toolName: z.enum(["search", "grep", "glob", "web_search"]).optional(),
content: z.string().optional(),
filePaths: z.array(z.string()).optional(),
webResults: z
.array(
z.object({
title: z.string(),
url: z.string(),
}),
)
.optional(),
annotations: z.array(z.string()).optional(),
numFiles: z.number().optional(),
numMatches: z.number().optional(),
durationMs: z.number().optional(),
durationSeconds: z.number().optional(),
truncated: z.boolean().optional(),
mode: z.enum(["content", "files_with_matches", "count"]).optional(),
}),
z.object({
type: z.literal("fetch"),
url: z.string(),
prompt: z.string().optional(),
result: z.string().optional(),
code: z.number().optional(),
codeText: z.string().optional(),
bytes: z.number().optional(),
durationMs: z.number().optional(),
}),
z.object({
type: z.literal("sub_agent"),
subAgentType: z.string().optional(),
description: z.string().optional(),
log: z.string(),
actions: z.array(
z.object({
title: z.string(),
url: z.string(),
index: z.number().int().positive(),
toolName: z.string(),
summary: z.string().optional(),
}),
)
.optional(),
annotations: z.array(z.string()).optional(),
numFiles: z.number().optional(),
numMatches: z.number().optional(),
durationMs: z.number().optional(),
durationSeconds: z.number().optional(),
truncated: z.boolean().optional(),
mode: z.enum(["content", "files_with_matches", "count"]).optional(),
}),
z.object({
type: z.literal("fetch"),
url: z.string(),
prompt: z.string().optional(),
result: z.string().optional(),
code: z.number().optional(),
codeText: z.string().optional(),
bytes: z.number().optional(),
durationMs: z.number().optional(),
}),
z.object({
type: z.literal("sub_agent"),
subAgentType: z.string().optional(),
description: z.string().optional(),
log: z.string(),
actions: z.array(
z.object({
index: z.number().int().positive(),
toolName: z.string(),
summary: z.string().optional(),
}),
),
}),
z.object({
type: z.literal("plain_text"),
label: z.string().optional(),
text: z.string().optional(),
icon: z.enum(TOOL_CALL_ICON_NAMES).optional(),
}),
z.object({
type: z.literal("plan"),
text: z.string(),
}),
z.object({
type: z.literal("unknown"),
input: UnknownValueSchema,
output: UnknownValueSchema,
}),
]);
),
}),
z.object({
type: z.literal("plain_text"),
label: z.string().optional(),
text: z.string().optional(),
icon: z.enum(TOOL_CALL_ICON_NAMES).optional(),
}),
z.object({
type: z.literal("plan"),
text: z.string(),
}),
z.object({
type: z.literal("unknown"),
input: UnknownValueSchema,
output: UnknownValueSchema,
}),
]);
const ToolCallBasePayloadSchema = z
.object({
@@ -2060,29 +2065,34 @@ const WorkspaceGitHubRuntimePayloadSchema = z
.optional()
.nullable();
export const WorkspaceDescriptorPayloadSchema = z.object({
id: z.string(),
projectId: z.string(),
projectDisplayName: z.string(),
projectRootPath: z.string(),
workspaceDirectory: z.string(),
projectKind: z.enum(["git", "non_git", "directory"]),
// COMPAT(workspaces): keep legacy directory workspace kind parseable.
workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]),
name: z.string(),
status: WorkspaceStateBucketSchema,
activityAt: z.string().nullable(),
diffStat: z
.object({
additions: z.number(),
deletions: z.number(),
})
.nullable()
.optional(),
scripts: z.array(WorkspaceScriptPayloadSchema).default([]),
gitRuntime: WorkspaceGitRuntimePayloadSchema,
githubRuntime: WorkspaceGitHubRuntimePayloadSchema,
});
export const WorkspaceDescriptorPayloadSchema = z
.object({
id: z.string(),
projectId: z.string(),
projectDisplayName: z.string(),
projectRootPath: z.string(),
workspaceDirectory: z.string().optional(),
projectKind: z.enum(["git", "non_git", "directory"]),
// COMPAT(workspaces): keep legacy directory workspace kind parseable.
workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]),
name: z.string(),
status: WorkspaceStateBucketSchema,
activityAt: z.string().nullable(),
diffStat: z
.object({
additions: z.number(),
deletions: z.number(),
})
.nullable()
.optional(),
scripts: z.array(WorkspaceScriptPayloadSchema).default([]),
gitRuntime: WorkspaceGitRuntimePayloadSchema,
githubRuntime: WorkspaceGitHubRuntimePayloadSchema,
})
.transform((workspace) => ({
...workspace,
workspaceDirectory: workspace.workspaceDirectory ?? workspace.projectRootPath,
}));
export const AgentUpdateMessageSchema = z.object({
type: z.literal("agent_update"),
@@ -254,6 +254,33 @@ describe("workspace message schemas", () => {
]);
});
test("parses legacy workspace descriptors without workspaceDirectory", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: {
id: "legacy-workspace",
projectId: "legacy-project",
projectDisplayName: "repo",
projectRootPath: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "repo",
status: "done",
activityAt: null,
scripts: [],
},
},
});
expect(parsed.type).toBe("workspace_update");
if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") {
throw new Error("Expected workspace_update upsert payload");
}
expect(parsed.payload.workspace.workspaceDirectory).toBe("/repo");
});
test("parses legacy workspace descriptor enum values", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_update",
@@ -194,6 +194,24 @@ describe("checkout git utilities", () => {
expect(branch).toBeNull();
});
it("returns the branch being rebased when HEAD is detached during a rebase", async () => {
execSync("git checkout -b feature/rebase-test", { cwd: repoDir });
writeFileSync(join(repoDir, "file.txt"), "feature\n");
execSync("git add file.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'feature change'", { cwd: repoDir });
execSync("git checkout main", { cwd: repoDir });
writeFileSync(join(repoDir, "file.txt"), "main\n");
execSync("git add file.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'main change'", { cwd: repoDir });
execSync("git checkout feature/rebase-test", { cwd: repoDir });
expect(() => execSync("git rebase main", { cwd: repoDir, stdio: "pipe" })).toThrow();
const branch = await getCurrentBranch(repoDir);
expect(branch).toBe("feature/rebase-test");
});
it("handles status/diff/commit in a normal repo", async () => {
writeFileSync(join(repoDir, "file.txt"), "updated\n");
+23 -1
View File
@@ -1,6 +1,6 @@
import { resolve, dirname, basename } from "path";
import { existsSync, realpathSync } from "fs";
import { open as openFile, stat as statFile } from "fs/promises";
import { open as openFile, readFile, stat as statFile } from "fs/promises";
import { TTLCache } from "@isaacs/ttlcache";
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
@@ -701,12 +701,34 @@ export async function getCurrentBranch(cwd: string): Promise<string | null> {
env: READ_ONLY_GIT_ENV,
});
const branch = stdout.trim();
if (branch === "HEAD") {
return await getRebaseHeadBranch(cwd);
}
return branch.length > 0 ? branch : null;
} catch {
return null;
}
}
async function getRebaseHeadBranch(cwd: string): Promise<string | null> {
for (const path of ["rebase-merge/head-name", "rebase-apply/head-name"]) {
try {
const { stdout } = await runGitCommand(["rev-parse", "--git-path", path], {
cwd,
env: READ_ONLY_GIT_ENV,
});
const headName = (await readFile(resolve(cwd, stdout.trim()), "utf8")).trim();
if (headName.startsWith("refs/heads/")) {
return headName.slice("refs/heads/".length) || null;
}
return headName || null;
} catch {
// Try the other rebase backend.
}
}
return null;
}
async function getWorktreeRoot(cwd: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.60-beta.2",
"version": "0.1.60",
"private": true,
"type": "module",
"scripts": {