Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andlikaixuan cd845129e9 test(app): reproduce lost floating panel hosts after overlap
Preserve a failing reproduction with real portal hosts. Removing the newest host must leave an older mounted host measurable.

Co-authored-by: likaixuan <likaixuan@mucang.cn>
2026-11-06 16:00:30 +00:00
55 changed files with 209 additions and 980 deletions
-6
View File
@@ -1,11 +1,5 @@
# Changelog
## 0.15.3 - 2026-11-06
### Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+ ([#8918](https://github.com/getpaseo/paseo/pull/8918) by [@dgreensp](https://github.com/dgreensp))
## 0.15.2 - 2026-11-05
### Added
-6
View File
@@ -1,11 +1,5 @@
# Changelog
## 0.15.3 - 2026-11-06
### 修复
- 修复了 iOS 26 及更高版本中打开或收起键盘时聊天内容变模糊的问题 ([#8918](https://github.com/getpaseo/paseo/pull/8918),贡献者:[@dgreensp](https://github.com/dgreensp))
## 0.15.2 - 2026-11-05
### 新增
-1
View File
@@ -34,7 +34,6 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
- **A turn boundary commits the queue before turn liveness changes.** The final reply arrives a few milliseconds before `turn_completed`, so it is still queued when the turn ends. `applyStreamEvent` in `viewed-timeline-sync.ts` flushes the agent's queue first; otherwise the chat paints a finished turn with no reply, drops the footer, and jumps twice.
- **A history row re-renders only when its item or layout item identity changes.** Every
coalesced tick hands the list a new layout. Without a memo boundary each tick re-rendered every
mounted row (measured on the inverted FlatList native used before LegendList: about 50 rows on a
-3
View File
@@ -195,9 +195,6 @@ See the [public usage source reference](../public-docs/plugins/reference.md#usag
contract, account and window identity, provider-derived period names, login fallback, and
read-only credential rules. Usage adapters own the interpretation of provider fields; the app
renders their names and resolves pins without provider-specific duration guesses.
Claude's OAuth usage endpoint is undocumented. Extra-usage amounts are reported in minor currency
units and displayed only for USD; the balance contract does not represent other currencies.
Codex individual spending limits provide percentages, not dollar amounts.
---
@@ -1,4 +0,0 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-vwivjBU5VFpM9VD7Wx4IKHN6xEVlg/KBMJCCMuA1JEw=
sha256-wO0vJVhh1urkt4q9NiQ0RSXUj++WWuHTneWSPelU+JQ=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.15.3",
"version": "0.15.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.15.3",
"version": "0.15.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37468,7 +37468,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.15.3",
"version": "0.15.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39637,13 +39637,13 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/server": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/server": "0.15.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39890,11 +39890,11 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -39906,7 +39906,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40149,7 +40149,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.3",
"version": "0.15.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40195,7 +40195,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/language": "6.12.4",
@@ -40225,7 +40225,7 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
@@ -40233,8 +40233,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40244,8 +40244,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40267,7 +40267,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"semver": "^7.8.5",
@@ -40295,7 +40295,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
@@ -40517,17 +40517,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/highlight": "0.15.3",
"@getpaseo/plugin": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/highlight": "0.15.2",
"@getpaseo/plugin": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41474,7 +41474,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.15.3",
"version": "0.15.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43084,7 +43084,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.15.3",
"version": "0.15.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.15.3",
"version": "0.15.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -1,200 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, test as baseTest } from "../support/fixtures";
import { expectAgentIdle } from "../support/helpers/agent-stream";
import { submitMessage } from "../support/helpers/composer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
// A turn ends with its final reply, `turn_completed`, and the idle status snapshot arriving
// within a few milliseconds of each other. These tests hold all three, let the running
// turn paint, then release them together and sample every painted frame.
interface Box {
top: number;
height: number;
}
interface TurnEndFrame {
at: number;
running: boolean;
footer: Box | null;
userRow: Box | null;
replyVisible: boolean;
}
declare global {
interface Window {
__turnEndFrames?: { active: boolean; frames: TurnEndFrame[] };
}
}
const test = baseTest.extend<{ agent: MockAgentWorkspace }>({
agent: async ({ page: _page }, provide) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "turn-end-",
title: "Turn end",
initialPrompt: "emit 4 tool agent stream updates",
});
await agent.client.waitForFinish(agent.agentId, 15_000);
await provide(agent);
await agent.cleanup();
},
});
async function recordTurnEndFrames(
page: Page,
input: { prompt: string; reply: string | null },
): Promise<void> {
await page.evaluate(({ prompt, reply }) => {
const state = { active: true, frames: [] as TurnEndFrame[] };
const isVisible = (element: Element | null | undefined) => Boolean(element?.checkVisibility());
const boxOf = (element: Element | null | undefined): Box | null => {
if (!element || !isVisible(element)) return null;
const rect = element.getBoundingClientRect();
return rect.height > 0 ? { top: rect.top, height: rect.height } : null;
};
const sample = () => {
const viewport = Array.from(
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
).find((candidate) => isVisible(candidate));
const footers = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
.map(boxOf)
.filter((box): box is Box => box !== null)
.sort((left, right) => left.top - right.top);
const userRow = Array.from(
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
).find((candidate) => candidate.textContent?.includes(prompt));
const replyRow =
reply === null
? undefined
: Array.from(viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? []).find(
(candidate) => candidate.textContent?.includes(reply),
);
state.frames.push({
at: performance.now(),
running: Boolean(viewport?.querySelector('[data-testid="turn-working-indicator"]')),
footer: footers.at(-1) ?? null,
userRow: boxOf(userRow),
replyVisible: boxOf(replyRow) !== null,
});
if (state.active) requestAnimationFrame(() => setTimeout(sample, 0));
};
window.__turnEndFrames = state;
requestAnimationFrame(() => setTimeout(sample, 0));
}, input);
}
async function recordPaintsFor(page: Page, durationMs: number): Promise<void> {
const until = await page.evaluate((duration) => performance.now() + duration, durationMs);
await expect
.poll(() => page.evaluate(() => window.__turnEndFrames?.frames.at(-1)?.at ?? 0))
.toBeGreaterThanOrEqual(until);
}
async function stopRecording(page: Page): Promise<TurnEndFrame[]> {
return page.evaluate(() => {
const state = window.__turnEndFrames;
if (!state) throw new Error("Turn end frames were not recorded");
state.active = false;
return state.frames;
});
}
async function recordTurnEnd(
page: Page,
agent: MockAgentWorkspace,
input: { prompt: string; reply: string | null },
): Promise<TurnEndFrame[]> {
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectAgentIdle(page);
if (input.reply === null) {
gate.setAgentStreamItemSuppressed("assistant_message", true);
} else {
gate.holdNextAgentStreamItem("assistant_message");
}
gate.holdNextAgentStreamEvent("turn_completed");
// The daemon sends more than one idle snapshot; hold the first and drop the rest until release.
gate.holdNextAgentUpdate(agent.agentId, "idle");
gate.setServerMessageSuppressed("agent_update", true);
await recordTurnEndFrames(page, input);
await submitMessage(page, input.prompt);
await Promise.all([
input.reply === null ? Promise.resolve() : gate.waitForHeldAgentStreamItem("assistant_message"),
gate.waitForHeldAgentStreamEvent("turn_completed"),
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
]);
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await recordPaintsFor(page, 200);
if (input.reply !== null) gate.releaseHeldAgentStreamItem("assistant_message");
gate.releaseHeldAgentStreamEvent("turn_completed");
gate.releaseHeldAgentUpdate(agent.agentId, "idle");
gate.setServerMessageSuppressed("agent_update", false);
await expectAgentIdle(page);
await recordPaintsFor(page, 300);
return stopRecording(page);
}
function describeFrames(frames: TurnEndFrame[]): string {
return frames
.map(
(frame, index) =>
`${index}: ${frame.running ? "running " : "complete"} footer=${
frame.footer ? `${frame.footer.top.toFixed(1)}+${frame.footer.height.toFixed(1)}` : "none"
} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
)
.join("\n");
}
function expectSteadyTurnEnd(frames: TurnEndFrame[], input: { reply: string | null }): void {
const firstRunning = frames.findIndex((frame) => frame.running && frame.footer !== null);
const firstComplete = frames.findIndex((frame, index) => index > firstRunning && !frame.running);
const record = `frames:\n${describeFrames(frames)}`;
expect(firstRunning, record).toBeGreaterThanOrEqual(0);
expect(firstComplete, record).toBeGreaterThan(firstRunning);
const transition = frames.slice(firstRunning);
const runningHeight = transition[0].footer?.height;
expect(
transition.map((frame) => frame.footer?.height ?? null),
`the footer must stay painted at one height\n${record}`,
).toEqual(transition.map(() => runningHeight));
if (input.reply !== null) {
expect(
frames.slice(firstComplete).every((frame) => frame.replyVisible),
`a completed turn must show its reply in the same frame\n${record}`,
).toBe(true);
}
// Tool rows land while the turn runs; only the turn end itself must be one step.
const ending = frames.slice(firstComplete - 1);
const promptMoves = ending
.slice(1)
.filter((frame, index) => frame.userRow?.top !== ending[index].userRow?.top);
expect(
promptMoves.length,
`the prompt must move at most once as the turn ends\n${record}`,
).toBeLessThanOrEqual(1);
}
test.describe("Turn end", () => {
test("ends a plain reply without moving or dropping its footer", async ({ page, agent }) => {
const input = { prompt: "emit 1 coalesced agent stream updates", reply: "stress-update-0" };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
test("ends a tool turn with its reply and completed footer together", async ({ page, agent }) => {
const input = { prompt: "emit 3 tool agent stream updates", reply: "Completed 3 commands" };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
test("keeps the footer height when a turn ends on a tool call", async ({ page, agent }) => {
const input = { prompt: "emit 2 tool agent stream updates", reply: null };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
});
@@ -5,10 +5,8 @@ import {
holdDirectorySuggestionResponses,
expectOneLineTruncatedFileResult,
expectStableCommandCenterLayout,
expectLayoutObservationToCatchPanelShift,
failDirectorySuggestionRequests,
startCommandCenterLayoutObservation,
shiftOutsideCommandCenter,
} from "../support/helpers/command-center-file-search";
import {
scrollCommandCenterAwayFromSelection,
@@ -86,9 +84,7 @@ test("workspace file search stays geometrically stable through delayed loading a
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0);
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await shiftOutsideCommandCenter(page);
await expectStableCommandCenterLayout(page);
await expectLayoutObservationToCatchPanelShift(page);
await row.click();
await expectFileTabOpen(page, FILE_PATH);
@@ -1,8 +1,6 @@
import { test } from "../support/fixtures";
import {
closeDiagramFullscreen,
expectInlineDiagramFitsViewport,
toggleDiagramSourceAndVerifyControls,
closeDiagramFullscreenFromOutside,
expectCompletedDiagram,
expectFullscreenDiagram,
@@ -23,36 +21,6 @@ const MERMAID_DIAGRAM = [
"```",
].join("\n");
test("bounds a tall inline diagram", async ({ page }) => {
test.setTimeout(120_000);
const diagram = [
"```mermaid",
"flowchart TD",
...Array.from({ length: 30 }, (_, i) => `N${i} --> N${i + 1}`),
"```",
].join("\n");
const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-tall-",
title: "Tall Mermaid",
featureValues: {
mockStreamingAssistantResponse: diagram,
mockStreamingAssistantIntervalMs: 10,
},
});
try {
await openAgentRoute(page, agent);
await requestDiagram(agent);
await waitForDiagramTurnToComplete(agent);
await expectCompletedDiagram(page, ["N0", "N30"]);
await expectInlineDiagramFitsViewport(page);
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["N0", "N30"]);
await closeDiagramFullscreen(page);
} finally {
await agent.cleanup();
}
});
test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) => {
test.setTimeout(120_000);
@@ -72,10 +40,6 @@ test("opens a Mermaid diagram fullscreen and closes it again", async ({ page })
await expectCompletedDiagram(page, ["Start", "Ship"]);
});
await test.step("Source toggles preserve the diagram and exclude hidden controls", async () => {
await toggleDiagramSourceAndVerifyControls(page);
});
await test.step("The toolbar opens the diagram fullscreen", async () => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Review", "Ship"]);
@@ -45,11 +45,6 @@ process.stdin.on("data", (chunk) => {
for (;;) {
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
if (!paste) break;
const pasted = JSON.stringify(paste[1]).replace(
/\s/g,
(space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"),
);
process.stdout.write("PASTED:" + pasted + "\r\n");
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
@@ -207,21 +202,6 @@ test.describe("Terminal protocol queries", () => {
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
}
// Every bracketed paste body the receiver consumed, in order and joined: what a
// shell would read as one command line.
async function pastedText(terminalId: string): Promise<string> {
const output = await terminalOutput(terminalId);
return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)]
.map(([, json]) => JSON.parse(json) as string)
.join("");
}
// Exactly one ASCII space after each quoted path and nothing else between them,
// so the paths stay separate shell words.
async function expectSeparatePastedPaths(terminalId: string): Promise<void> {
await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/);
}
test("pastes images from two panes into their own terminals as uploaded host files", async ({
page,
}) => {
@@ -258,23 +238,6 @@ test.describe("Terminal protocol queries", () => {
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
});
test("keeps the paths of two consecutive drops to another machine apart", async ({ page }) => {
const terminal = await openFileReceiver(page, "file-drop-remote-twice");
const first = { name: "first.png", type: "image/png", bytes: pngBytes(10) };
const second = { name: "second.txt", type: "text/plain", bytes: Buffer.from("second drop") };
try {
await dropFiles(page, [first]);
await expectTerminalOutput(terminal.id, fileRead(first.bytes, "upload"));
await dropFiles(page, [second]);
await expectTerminalOutput(terminal.id, fileRead(second.bytes, "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
@@ -407,6 +407,7 @@ test("opening a turn keeps the received message above it clipped on every frame"
.getByTestId("user-message")
.filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) });
await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible();
await bubble.scrollIntoViewIfNeeded();
await watchReceivedMessagePreview(page, "Sent by Reviewer");
await page
@@ -99,7 +99,6 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
const observedElements = new WeakSet<Element>();
const resizeObserver = new ResizeObserver(record);
const mutationObserver = new MutationObserver(discover);
let performanceObserver: PerformanceObserver | undefined;
function record(): void {
if (!observation.active) return;
@@ -133,7 +132,6 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
if (!observation.active) {
mutationObserver.disconnect();
resizeObserver.disconnect();
performanceObserver?.disconnect();
return;
}
record();
@@ -143,16 +141,9 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
if (typeof PerformanceObserver !== "undefined") {
try {
performanceObserver = new PerformanceObserver((list) => {
const panel = document.querySelector(selectors.panel);
const performanceObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const shift = entry as PerformanceEntry & {
value: number;
sources: Array<{ node: Node | null }>;
};
if (shift.sources.some((source) => source.node && panel?.contains(source.node))) {
observation.layoutShift += shift.value;
}
observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0;
}
});
performanceObserver.observe({ type: "layout-shift", buffered: false });
@@ -183,54 +174,7 @@ export async function expectStableCommandCenterLayout(page: Page): Promise<void>
).toBeLessThanOrEqual(1);
}
}
expect(
observation.layoutShift,
`command center layoutShift: ${JSON.stringify(observation)}`,
).toBeLessThanOrEqual(0.001);
}
/** Exercise an unrelated page shift while the real search panel is being observed. */
export async function shiftOutsideCommandCenter(page: Page): Promise<void> {
await shiftLayoutProbe(page.locator("body"));
}
export async function expectLayoutObservationToCatchPanelShift(page: Page): Promise<void> {
await startCommandCenterLayoutObservation(page);
await shiftLayoutProbe(page.getByTestId("command-center-panel"));
await expect(expectStableCommandCenterLayout(page)).rejects.toThrow("command center layoutShift");
}
async function shiftLayoutProbe(parent: Locator): Promise<void> {
const recordedShift = await parent.evaluate(async (container) => {
const sibling = document.createElement("div");
sibling.textContent = "Layout probe";
sibling.style.cssText =
"position:fixed;left:0;top:0;width:300px;height:100px;background:white;z-index:99999";
container.append(sibling);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const value = await new Promise<number>((resolve) => {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const shift = entry as PerformanceEntry & {
value: number;
sources: Array<{ node: Node | null }>;
};
if (shift.sources.some((source) => source.node === sibling)) {
observer.disconnect();
resolve(shift.value);
}
}
});
observer.observe({ type: "layout-shift", buffered: false });
sibling.style.left = "400px";
});
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
sibling.remove();
return value;
});
expect(recordedShift, "the browser recorded a substantial probe shift").toBeGreaterThan(0.001);
expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001);
}
export async function expectOneLineTruncatedFileResult(
@@ -148,59 +148,3 @@ export async function expectFullscreenDiagram(
export async function expectFullscreenDiagramClosed(page: Page): Promise<void> {
await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0);
}
/** Tall diagrams stay within the screen so their controls remain reachable. */
export async function expectInlineDiagramFitsViewport(page: Page): Promise<void> {
const viewport = page.getByTestId("mermaid-viewport").last();
const box = await viewport.boundingBox();
expect(box, "the inline diagram has measurable layout").not.toBeNull();
const screenHeight = page.viewportSize()!.height;
expect(box!.height, "the inline diagram fits within the screen").toBeLessThan(screenHeight);
const contentHeight = await diagramSvg(page, "mermaid-viewport").evaluate(
(svg) => (svg as SVGSVGElement).viewBox.baseVal.height,
);
expect(box!.height, "a tall diagram is bounded rather than expanding the chat").toBeLessThan(
contentHeight,
);
}
/** Exercise pointer toggles and the controls encountered along that same journey. */
export async function toggleDiagramSourceAndVerifyControls(page: Page): Promise<void> {
const viewport = page.getByTestId("mermaid-viewport").last();
const iframe = await viewport.locator("iframe").elementHandle();
for (let iteration = 0; iteration < 3; iteration++) {
await viewport.hover();
await page.getByRole("button", { name: "View source" }).last().click();
const viewDiagram = page.getByRole("button", { name: "View diagram" }).last();
await viewDiagram.click({ timeout: 10_000 });
await expect(viewDiagram).toHaveCount(0);
await expectFullscreenDiagramClosed(page);
expect(
await viewport.locator("iframe").evaluate((node, original) => node === original, iframe),
"switching views preserves the rendered diagram",
).toBe(true);
}
await viewport.hover();
await page.getByRole("button", { name: "View source" }).last().click();
const hiddenFullscreen = viewport.getByRole("button", {
name: "Fullscreen",
includeHidden: true,
});
await hiddenFullscreen.focus();
await expect(hiddenFullscreen).not.toBeFocused();
await page.getByRole("button", { name: "View diagram" }).last().click();
await viewport.hover();
const fullscreen = viewport.getByRole("button", { name: "Fullscreen", exact: true });
const box = await viewport.boundingBox();
const control = await fullscreen.boundingBox();
expect(box).not.toBeNull();
expect(control).not.toBeNull();
expect(control!.y, "the toolbar starts inside the preview").toBeGreaterThanOrEqual(box!.y);
expect(control!.y + control!.height, "the toolbar fits inside the preview").toBeLessThanOrEqual(
box!.y + box!.height,
);
await viewport.getByRole("button", { name: "Zoom in" }).click();
await expect(viewport.getByRole("button", { name: "Reset view" })).toBeEnabled();
await viewport.getByRole("button", { name: "Reset view" }).click();
await expect(viewport.getByRole("button", { name: "Reset view" })).toBeDisabled();
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.15.3",
"version": "0.15.2",
"private": true,
"main": "index.ts",
"scripts": {
+3 -11
View File
@@ -23,10 +23,6 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[8];
// Every footer variant (running, completed with Copy, completed with only its duration) gets
// this content height, so a turn ending swaps the footer's controls without moving the rows
// above it.
const TURN_FOOTER_CONTENT_HEIGHT = 24;
export type TurnContentStrategy = StreamStrategy;
export type AssistantTurnForkHandler = (input: {
@@ -212,11 +208,7 @@ function CompletedTurnFooter({
function TurnFooterRow({ children }: { children: ReactNode }) {
const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []);
return (
<View style={rowStyle} testID="turn-footer">
{children}
</View>
);
return <View style={rowStyle}>{children}</View>;
}
const stylesheet = StyleSheet.create((theme) => ({
@@ -237,11 +229,11 @@ const stylesheet = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
minHeight: 24,
paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
},
turnFooterContent: {
height: TURN_FOOTER_CONTENT_HEIGHT,
height: 24,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
@@ -72,7 +72,7 @@ function MermaidFenceHostImpl({
() => [
getDiagramBoxStyle(textStyle),
containerStyle,
{ height: Math.min(runtimeHeight, MAX_PREVIEW_HEIGHT), minHeight: MIN_DIAGRAM_BOX_HEIGHT },
{ height: runtimeHeight, minHeight: MIN_DIAGRAM_BOX_HEIGHT },
],
[runtimeHeight, textStyle],
);
@@ -111,22 +111,20 @@ function MermaidFenceHostImpl({
) : null}
</View>
) : null}
<div inert={!diagramVisible} style={boundaryStyle}>
<ZoomableViewport
accessibilityLabel={t("message.diagram.diagram")}
actions={actions}
contentSize={diagramSize}
style={viewportStyle}
testID="mermaid-viewport"
wheelActivation="modifier"
>
<MermaidIframeRuntime
request={request}
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
</ZoomableViewport>
</div>
<ZoomableViewport
accessibilityLabel={t("message.diagram.diagram")}
actions={actions}
contentSize={diagramSize}
style={viewportStyle}
testID="mermaid-viewport"
wheelActivation="modifier"
>
<MermaidIframeRuntime
request={request}
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
</ZoomableViewport>
{isFullscreen && visible ? (
<MermaidFullscreenViewer
source={visible.source}
@@ -144,8 +142,6 @@ const MEASURING_SIZE = { width: 240, height: 240 };
* with `overflow: hidden`, so a shorter box leaves the buttons half-clipped and unclickable.
*/
const MIN_DIAGRAM_BOX_HEIGHT = 56;
const MAX_PREVIEW_HEIGHT = 480;
const boundaryStyle: React.CSSProperties = { display: "contents" };
const sourceContainerStyle: ViewStyle = { position: "relative" };
/**
* The viewport's own root is `flex: 1`, so as a flex item it has `flex-basis: 0%` and that basis
@@ -0,0 +1,34 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, test } from "vitest";
import { Portal, PortalProvider } from "@gorhom/portal";
import { FloatingPanelPortalHost, measureFloatingPanelPortalHost } from "./floating-panel-portal";
describe("Floating panel host lifetime", () => {
test("removing the newest host preserves the surviving host measurement", async () => {
const container = document.createElement("div");
container.style.cssText = "position:relative;width:800px;height:600px";
document.body.appendChild(container);
const root = createRoot(container);
function Content({ overlap }: { overlap: boolean }) {
return (
<PortalProvider shouldAddRootHost={false}>
<FloatingPanelPortalHost name="workspace" />
{overlap && <FloatingPanelPortalHost name="workspace" />}
<Portal hostName="workspace" name="picker">
<div data-testid="picker">Commands</div>
</Portal>
</PortalProvider>
);
}
try {
await act(async () => root.render(<Content overlap={true} />));
expect(await measureFloatingPanelPortalHost("workspace")).not.toBeNull();
await act(async () => root.render(<Content overlap={false} />));
expect(await measureFloatingPanelPortalHost("workspace")).not.toBeNull();
} finally {
await act(async () => root.unmount());
container.remove();
}
});
});
+15 -1
View File
@@ -12,6 +12,7 @@ import {
refreshAgentInitializationTimeout,
} from "@/hooks/use-agent-initialization";
import type { StreamItem } from "@/types/stream";
import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducers";
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { requestTimelineReplacement } from "@/timeline/timeline-replacement";
import { type ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync";
@@ -359,6 +360,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const onStream = (message: SessionOutboundMessage) => {
if (message.type !== "agent_stream") return;
const { agentId, event, timestamp, seq, epoch } = message.payload;
const parsedTimestamp = new Date(timestamp);
const streamEvent = event;
if (
event.type === "turn_started" ||
event.type === "turn_completed" ||
@@ -367,7 +370,18 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
) {
voiceRuntime?.onTurnEvent(serverId, agentId, event.type);
}
sync.applyStreamEvent(agentId, { event, seq, epoch, timestamp: new Date(timestamp) });
const turnLiveness = deriveAgentStreamTurnLiveness([
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
]);
if (turnLiveness.length > 0) {
getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness);
}
sync.enqueueStreamEvent(agentId, {
event: streamEvent,
seq,
epoch,
timestamp: parsedTimestamp,
});
// NOTE: We don't update lastActivityAt on every stream event to prevent
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
-2
View File
@@ -2852,8 +2852,6 @@ export class HostRuntimeStore {
replica,
replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds),
drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId),
applyTurnLiveness: (agentId, transitions) =>
directory.applyAgentTurnLiveness(agentId, transitions),
ports,
});
}
@@ -74,8 +74,7 @@ describe("terminal file paste queue", () => {
firstUpload.resolve("/host/uploads/a.png");
await settle();
expect(host.pasted).toEqual(["'/host/uploads/a.png' ", "'/host/uploads/b.png' "]);
expect(host.pasted.join("")).toBe("'/host/uploads/a.png' '/host/uploads/b.png' ");
expect(host.pasted).toEqual(["'/host/uploads/a.png'", "'/host/uploads/b.png'"]);
expect(events).toEqual({ uploading: [true, false, true, false], uploaded: 2, failures: [] });
});
@@ -86,7 +85,7 @@ describe("terminal file paste queue", () => {
queue.add(Promise.resolve(file("shot.png")), host);
await settle();
expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png" ']);
expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png"']);
});
it("uploads under a file name that needs no shell quoting", async () => {
@@ -121,7 +120,7 @@ describe("terminal file paste queue", () => {
expect(events.failures).toEqual(["disk full"]);
expect(events.uploaded).toBe(1);
expect(host.pasted).toEqual(["'/host/uploads/b.png' "]);
expect(host.pasted).toEqual(["'/host/uploads/b.png'"]);
});
it("reports unreadable and oversized files without uploading them", async () => {
@@ -167,7 +166,7 @@ describe("terminal file paste queue", () => {
queue.add(Promise.resolve(file("next.png")), host);
await settle();
expect(host.pasted).toEqual(["'/host/uploads/next.png' "]);
expect(host.pasted).toEqual(["'/host/uploads/next.png'"]);
expect(events.uploading).toEqual([true, false, true, false]);
expect(queue.cancel()).toBe(false);
});
@@ -75,7 +75,7 @@ export class TerminalFilePasteQueue {
this.setUploading(true);
const path = await target.upload({ ...file, fileName: toShellSafeFileName(file.fileName) });
if (isCurrent()) {
target.paste(`${prepareTerminalPath(path)} `);
target.paste(prepareTerminalPath(path));
this.events.onUploaded();
}
} catch (error) {
+6 -90
View File
@@ -11,7 +11,6 @@ import type { CachedTimeline } from "@/runtime/replica-cache";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
import type { StreamItem } from "@/types/stream";
import type { TurnLivenessTransition } from "./turn-liveness";
import {
createTimelineReplica,
createViewedTimelineOwner,
@@ -41,13 +40,7 @@ function cachedTimeline(): CachedTimeline {
};
}
function createOwner(
storage: TimelineReplicaStorage,
applyTurnLiveness: (
agentId: string,
transitions: readonly TurnLivenessTransition[],
) => void = () => undefined,
): ViewedTimelineOwner {
function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner {
const replica = createTimelineReplica({
serverId: SERVER_ID,
storage,
@@ -58,7 +51,6 @@ function createOwner(
replica,
replaceDemandedAgentIds: () => undefined,
drainQueuedAgentMessage: () => undefined,
applyTurnLiveness,
ports: {
observe: () => ({ ready: Promise.resolve(), release: async () => undefined }),
readCursor: () => undefined,
@@ -106,7 +98,7 @@ describe("viewed timeline persistence", () => {
.status,
)
.toBe("painted");
owner.applyStreamEvent(AGENT_ID, {
owner.enqueueStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
@@ -155,7 +147,7 @@ describe("viewed timeline persistence", () => {
.map((entry) => entry.text);
};
expect.soft(replyTexts()).toEqual([complete]);
owner.applyStreamEvent(AGENT_ID, {
owner.enqueueStreamEvent(AGENT_ID, {
event: { type: "turn_completed", provider: "codex" } as AgentStreamEventPayload,
seq: 7,
epoch: "epoch-1",
@@ -289,7 +281,7 @@ describe("viewed timeline persistence", () => {
)
.toMatchObject({ status: "painted" });
first.applyStreamEvent(AGENT_ID, {
first.enqueueStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
@@ -468,7 +460,7 @@ describe("viewed timeline persistence", () => {
readTimeline: async () => undefined,
commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline),
});
owner.applyStreamEvent(AGENT_ID, {
owner.enqueueStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
@@ -533,7 +525,7 @@ describe("viewed timeline persistence", () => {
[AGENT_ID, 9],
["agent-2", 4],
] as const) {
owner.applyStreamEvent(agentId, {
owner.enqueueStreamEvent(agentId, {
event: {
type: "timeline",
provider: "codex",
@@ -551,81 +543,6 @@ describe("viewed timeline persistence", () => {
});
});
describe("turn boundaries", () => {
it("commits a turn's final reply before the turn stops running", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
applySynced(AGENT_ID, 8);
const committedKinds = () => {
const session = useSessionStore.getState().sessions[SERVER_ID];
return [
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
]
.filter((entry) => entry.id !== `network-${AGENT_ID}`)
.map((entry) => entry.kind);
};
const livenessChanges: { type: string; committed: string[] }[] = [];
const owner = createOwner(
{ readTimeline: async () => undefined, commitTimeline: () => undefined },
(_agentId, transitions) => {
for (const transition of transitions) {
livenessChanges.push({ type: transition.type, committed: committedKinds() });
}
},
);
const at = (second: number) => new Date(`2026-08-26T10:00:0${second}.000Z`);
// One frame's worth of live events: the frame flush that would commit them never runs.
owner.applyStreamEvent(AGENT_ID, {
event: { type: "turn_started", provider: "codex", turnId: "turn-1" },
seq: undefined,
epoch: undefined,
timestamp: at(1),
});
owner.applyStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
turnId: "turn-1",
item: {
type: "tool_call",
callId: "call-1",
name: "Bash",
status: "completed",
error: null,
detail: { type: "shell", command: "ls" },
},
} as AgentStreamEventPayload,
seq: 9,
epoch: "epoch-1",
timestamp: at(2),
});
owner.applyStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
turnId: "turn-1",
item: { type: "assistant_message", text: "Done.", messageId: "reply-1" },
} as AgentStreamEventPayload,
seq: 10,
epoch: "epoch-1",
timestamp: at(3),
});
owner.applyStreamEvent(AGENT_ID, {
event: { type: "turn_completed", provider: "codex", turnId: "turn-1" },
seq: undefined,
epoch: undefined,
timestamp: at(4),
});
expect(livenessChanges.at(-1)).toEqual({
type: "stream_close",
committed: ["tool_call", "assistant_message"],
});
owner.dispose();
});
});
describe("create handoff lifetime", () => {
const DRAFT_ID = "draft-1";
@@ -689,7 +606,6 @@ describe("create handoff lifetime", () => {
replica,
replaceDemandedAgentIds: () => undefined,
drainQueuedAgentMessage: () => undefined,
applyTurnLiveness: () => undefined,
ports: {
observe: () => ({ ready: Promise.resolve(), release: async () => undefined }),
readCursor: () => undefined,
@@ -21,14 +21,12 @@ import {
} from "@/utils/agent-initialization";
import {
createSessionAgentStreamReducerQueue,
deriveAgentStreamTurnLiveness,
type AgentStreamReducerEvent,
type ProcessTimelineResponseOutput,
processTimelineResponse,
} from "./session-stream-reducers";
import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan";
import { replaceWithCanonicalStream } from "@/types/stream";
import type { TurnLivenessTransition } from "./turn-liveness";
export interface TimelineReplicaStorage {
readTimeline(serverId: string, agentId: string): Promise<CachedTimeline | undefined>;
@@ -363,12 +361,7 @@ export type ViewedTimelineOwnerPorts = Omit<
export interface ViewedTimelineOwner extends ViewedTimelineSync {
applyTimelineResponse(payload: TimelineResponsePayload): void;
/**
* Accepts one live stream event. Content is coalesced to the next frame; a turn boundary
* commits the turn's queued content before its liveness changes, so the view never shows
* a finished turn without its final reply.
*/
applyStreamEvent(agentId: string, event: AgentStreamReducerEvent): void;
enqueueStreamEvent(agentId: string, event: AgentStreamReducerEvent): void;
flushStreamAgent(agentId: string): void;
}
@@ -377,7 +370,6 @@ export function createViewedTimelineOwner(input: {
replica: TimelineReplica;
replaceDemandedAgentIds: (agentIds: string[]) => void;
drainQueuedAgentMessage: (agentId: string) => void;
applyTurnLiveness: (agentId: string, transitions: readonly TurnLivenessTransition[]) => void;
ports: ViewedTimelineOwnerPorts;
}): ViewedTimelineOwner {
const forcedTailReplacements = new Set<string>();
@@ -417,12 +409,8 @@ export function createViewedTimelineOwner(input: {
});
if (accepted) input.replica.timelineUpdated(payload.agentId);
},
applyStreamEvent(agentId, event) {
enqueueStreamEvent(agentId, event) {
streamQueue.enqueue(agentId, event);
const turnLiveness = deriveAgentStreamTurnLiveness([event]);
if (turnLiveness.length === 0) return;
streamQueue.flushAgent(agentId);
input.applyTurnLiveness(agentId, turnLiveness);
},
flushStreamAgent(agentId) {
streamQueue.flushAgent(agentId);
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.15.3",
"version": "0.15.2",
"description": "Paseo CLI - control your AI coding agents from the command line",
"license": "Apache-2.0",
"bin": {
@@ -29,9 +29,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/server": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/server": "0.15.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.15.3",
"version": "0.15.2",
"description": "Paseo client SDK package",
"license": "Apache-2.0",
"files": [
@@ -48,8 +48,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.15.3",
"version": "0.15.2",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.3",
"version": "0.15.2",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.15.3",
"version": "0.15.2",
"description": "Paseo plugin SDK package",
"license": "Apache-2.0",
"files": [
@@ -78,8 +78,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -89,8 +89,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.15.3",
"version": "0.15.2",
"description": "Paseo shared protocol schemas and wire types",
"license": "Apache-2.0",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.15.3",
"version": "0.15.2",
"description": "Paseo relay for bridging daemon and client connections",
"license": "Apache-2.0",
"files": [
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.15.3",
"version": "0.15.2",
"description": "Paseo backend server",
"license": "Apache-2.0",
"files": [
@@ -108,11 +108,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/highlight": "0.15.3",
"@getpaseo/plugin": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/highlight": "0.15.2",
"@getpaseo/plugin": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -3286,78 +3286,6 @@ test.each([
},
);
test("createAgent and resume give the agent its workspace id", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-workspace-env-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class CaptureClient extends TestAgentClient {
createLaunchContext: AgentLaunchContext | undefined;
resumeLaunchContext: AgentLaunchContext | undefined;
override async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.createLaunchContext = launchContext;
return new TestAgentSession(config);
}
override async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.resumeLaunchContext = launchContext;
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
return new TestAgentSession({ ...metadata, ...overrides, provider: "codex", cwd: workdir });
}
}
const client = new CaptureClient();
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000140",
});
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: "ws-agent-home",
});
expect(client.createLaunchContext?.env).toEqual({
PASEO_AGENT_ID: snapshot.id,
PASEO_AGENT_CWD: workdir,
PASEO_WORKSPACE_ID: "ws-agent-home",
});
// The stored record supplies the workspace when the caller doesn't.
const handle: AgentPersistenceHandle = {
provider: "codex",
sessionId: "workspace-env-session",
metadata: { provider: "codex", cwd: workdir },
};
const expectedEnv = {
PASEO_AGENT_ID: snapshot.id,
PASEO_AGENT_CWD: workdir,
PASEO_WORKSPACE_ID: "ws-agent-home",
};
await manager.closeAgent(snapshot.id);
const resumed = await manager.resumeAgentFromPersistence(handle, { cwd: workdir }, snapshot.id);
expect(client.resumeLaunchContext?.env).toEqual(expectedEnv);
expect(resumed.workspaceId).toBe("ws-agent-home");
expect((await storage.get(snapshot.id))?.workspaceId).toBe("ws-agent-home");
// Each restart reads what the previous resume stored.
client.resumeLaunchContext = undefined;
await manager.closeAgent(snapshot.id);
await manager.resumeAgentFromPersistence(handle, { cwd: workdir }, snapshot.id);
expect(client.resumeLaunchContext?.env).toEqual(expectedEnv);
});
test("createAgent passes persistSession to provider create options", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -4803,7 +4731,6 @@ test("importProviderSession imports the selected session without listing and pub
env: {
PASEO_AGENT_ID: imported.id,
PASEO_AGENT_CWD: workdir,
PASEO_WORKSPACE_ID: "ws-imported",
},
});
expect(imported.lifecycle).toBe("idle");
@@ -664,36 +664,6 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri
return Object.assign(new Error(message), { name: "AbortError" });
}
// PASEO_WORKSPACE_ID lets an agent rename or archive its own workspace; several workspaces can
// share one cwd, so the agent can't work it out from PASEO_AGENT_CWD.
function agentIdentityEnv({
agentId,
cwd,
opening,
}: {
agentId: string;
cwd: string;
opening: { workspaceId?: string | null } | undefined;
}): Record<string, string> {
const workspaceId = opening?.workspaceId;
return {
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
...(workspaceId ? { PASEO_WORKSPACE_ID: workspaceId } : {}),
};
}
// Launch env and the registered agent must agree, or the next snapshot erases the stored workspace.
function resolveResumeWorkspaceId({
options,
record,
}: {
options: { workspaceId?: string } | undefined;
record: Pick<StoredAgentRecord, "workspaceId"> | null;
}): string | undefined {
return options?.workspaceId ?? record?.workspaceId;
}
function validateAgentId(agentId: string, source: string): string {
const result = AgentIdSchema.safeParse(agentId);
if (!result.success) {
@@ -1601,7 +1571,6 @@ export class AgentManager {
const currentResumeOptions = { purpose } satisfies AgentResumeSessionOptions;
const launchEnv = record?.launchEnv;
const workspaceId = resolveResumeWorkspaceId({ options, record });
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
mergedConfig,
resolvedAgentId,
@@ -1624,7 +1593,7 @@ export class AgentManager {
{
reason: "resume",
purpose,
workspaceId,
workspaceId: options?.workspaceId ?? null,
title: record?.title ?? mergedConfig.title ?? null,
labels: record?.labels ?? options?.labels ?? {},
},
@@ -1640,7 +1609,6 @@ export class AgentManager {
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, {
...options,
workspaceId,
launchEnv,
persistence: handle,
restoring: true,
@@ -5806,7 +5774,8 @@ export class AgentManager {
mcpServerOwners,
env: {
...env,
...agentIdentityEnv({ agentId, cwd, opening }),
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
},
};
const useNativeTools =
@@ -349,11 +349,7 @@ type OpenCodeAgentConfig = Omit<AgentSessionConfig, "providerOptions"> & {
providerOptions: OpenCodeProviderOptions;
};
const OPENCODE_SESSION_ENV_KEYS = new Set([
"PASEO_AGENT_ID",
"PASEO_AGENT_CWD",
"PASEO_WORKSPACE_ID",
]);
const OPENCODE_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]);
function requiresDedicatedOpenCodeServer(
config: OpenCodeAgentConfig,
@@ -24,14 +24,7 @@ describe("OpenCode v2 helper isolation", () => {
expect(
requiresDedicatedV2Server(
{ provider: "opencode", cwd: "/tmp/project" },
{
agentId: "agent",
env: {
PASEO_AGENT_ID: "agent",
PASEO_AGENT_CWD: "/tmp/project",
PASEO_WORKSPACE_ID: "ws-1",
},
},
{ agentId: "agent", env: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" } },
),
).toBe(false);
});
@@ -72,7 +72,7 @@ export async function applyResumeOverrides(
// it must not force a dedicated server. Custom MCP is location-scoped, and any
// other env key belongs to the process, so both need isolation. Mirrors the v1
// adapter's `requiresDedicatedOpenCodeServer`.
const V2_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD", "PASEO_WORKSPACE_ID"]);
const V2_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]);
export function requiresDedicatedV2Server(
config: AgentSessionConfig,
@@ -48,8 +48,7 @@ afterEach(() => {
process.env.PATH = originalEnv.PATH;
process.env.PATHEXT = originalEnv.PATHEXT;
for (const dir of tempDirs.splice(0)) {
// Windows can briefly retain a lock on the copied executable after its probe exits.
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
rmSync(dir, { recursive: true, force: true });
}
});
@@ -18,7 +18,6 @@ import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-config
import { parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js";
import type {
AgentClient,
AgentLaunchContext,
AgentPersistenceHandle,
AgentRunResult,
AgentSession,
@@ -325,7 +324,6 @@ class RecordingMcpResumeClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities;
readonly resumeOverrides: Array<Partial<AgentSessionConfig> | undefined> = [];
readonly resumeLaunchContexts: Array<AgentLaunchContext | undefined> = [];
private readonly inner = createTestAgentClients({ supportsMcpServers: true }).codex;
constructor() {
@@ -343,10 +341,8 @@ class RecordingMcpResumeClient implements AgentClient {
resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.resumeOverrides.push(overrides);
this.resumeLaunchContexts.push(launchContext);
return this.inner.resumeSession(handle, overrides);
}
@@ -406,7 +402,7 @@ class RejectingMcpResumeClient implements AgentClient {
}
}
test("createAgent preserves the error snapshot when the initial turn cannot start", async () => {
test("createAgent fails when the initial turn cannot start", async () => {
const testAgent = new StubAgentClient({
sessionId: "start-turn-failure-session",
supportsStreaming: false,
@@ -425,18 +421,16 @@ test("createAgent preserves the error snapshot when the initial turn cannot star
await client.connect();
await client.fetchAgents({ subscribe: {} });
const created = await client.createAgent({
provider: "codex",
cwd: tmpCwd(),
title: "Start failure agent",
modeId: "full-access",
model: "gpt-5.4-mini",
initialPrompt: "Run exactly: sleep 30",
});
expect(created.status).toBe("error");
expect(created.lastError).toBe("Initial turn failed to start");
const fetched = await client.fetchAgent({ agentId: created.id });
expect(fetched?.agent.lastError).toBe("Initial turn failed to start");
await expect(
client.createAgent({
provider: "codex",
cwd: tmpCwd(),
title: "Start failure agent",
modeId: "full-access",
model: "gpt-5.4-mini",
initialPrompt: "Run exactly: sleep 30",
}),
).rejects.toThrow("Initial turn failed to start");
} finally {
await client.close();
await daemon.close();
@@ -1040,38 +1034,6 @@ test("resume_agent rehydrates the newest private MCP config from a redacted pers
}
});
test("resume_agent keeps the workspace of the agent it matches", async () => {
const cwd = tmpCwd();
const provider = new RecordingMcpResumeClient();
const localCtx = await createDaemonTestContext({
agentClients: { codex: provider },
});
try {
const opened = await localCtx.client.openProject(cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? "Workspace did not open");
}
const workspaceId = opened.workspace.id;
const created = await localCtx.client.createAgent({
config: { provider: "codex", cwd },
workspaceId,
});
if (!created.persistence) {
throw new Error("Expected a persistence handle");
}
await localCtx.client.archiveAgent(created.id);
const resumed = await localCtx.client.resumeAgent(created.persistence);
expect(provider.resumeLaunchContexts[0]?.env?.PASEO_WORKSPACE_ID).toBe(workspaceId);
expect(resumed.workspaceId).toBe(workspaceId);
} finally {
await localCtx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
});
test("resume_agent restores archive state when an MCP-capable provider returns an unsupported session", async () => {
const cwd = tmpCwd();
const provider = new RejectingMcpResumeClient();
@@ -1407,6 +1369,27 @@ test("creates agent and exercises lifecycle", async () => {
});
const createRequestId = `create-${Date.now()}`;
const createdStatusPromise = waitForSignal(15000, (resolve) => {
const unsubscribe = ctx.client.on("status", (message) => {
if (message.type !== "status") {
return;
}
const payload = message.payload as {
status?: string;
agentId?: string;
requestId?: string;
};
if (payload.status !== "agent_created") {
return;
}
if (payload.requestId !== createRequestId) {
return;
}
resolve(message);
});
return unsubscribe;
});
const agent = await ctx.client.createAgent({
...getFullAccessConfig("codex"),
cwd,
@@ -1421,8 +1404,30 @@ test("creates agent and exercises lifecycle", async () => {
const agentUpdate = await agentUpdatePromise;
expect(agentUpdate.payload.agent.id).toBe(agent.id);
const createdStatus = await createdStatusPromise;
expect((createdStatus.payload as { agentId?: string }).agentId).toBe(agent.id);
const failRequestId = `fail-${Date.now()}`;
const failedStatusPromise = waitForSignal(15000, (resolve) => {
const unsubscribe = ctx.client.on("status", (message) => {
if (message.type !== "status") {
return;
}
const payload = message.payload as {
status?: string;
requestId?: string;
};
if (payload.status !== "agent_create_failed") {
return;
}
if (payload.requestId !== failRequestId) {
return;
}
resolve(message);
});
return unsubscribe;
});
await expect(
ctx.client.createAgent({
...getFullAccessConfig("codex"),
@@ -1431,6 +1436,7 @@ test("creates agent and exercises lifecycle", async () => {
requestId: failRequestId,
}),
).rejects.toThrow("Working directory does not exist");
await failedStatusPromise;
let sawRefresh = false;
const unsubscribe = ctx.client.subscribe((event) => {
@@ -1609,11 +1615,6 @@ test("creates agent and exercises lifecycle", async () => {
}, 300000);
test("handles permission flow", async () => {
const permissionEvents = ctx.client.observeEvents([
"agent_permission_request",
"agent_permission_resolved",
]);
await permissionEvents.ready;
const cwd = tmpCwd();
const filePath = path.join(cwd, "permission.txt");
@@ -1682,7 +1683,6 @@ test("handles permission flow", async () => {
// Prevent unhandled rejections if the test fails before promises resolve.
await permissionRequestPromise.catch(() => {});
await permissionResolvedPromise.catch(() => {});
await permissionEvents.release();
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}
+1 -6
View File
@@ -4856,12 +4856,7 @@ export class Session {
: overrides;
let snapshot: ManagedAgent;
try {
snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
effectiveOverrides,
undefined,
{ workspaceId: matched?.record.workspaceId },
);
snapshot = await this.agentManager.resumeAgentFromPersistence(handle, effectiveOverrides);
} catch (error) {
if (matched?.didUnarchive && matched.originalArchivedAt) {
await this.agentManager.archiveSnapshot(matched.record.id, matched.originalArchivedAt);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.15.3",
"version": "0.15.2",
"private": true,
"type": "module",
"scripts": {
@@ -162,64 +162,6 @@ describe("claude usage source", () => {
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it.each([false, true])("reports USD extra usage with rolling windows %j", async (hasWindows) => {
writeClaudeCredentials(claudeHome, "at_valid");
fetchApi = mockFetch(
new Map([
[
"https://api.anthropic.com/api/oauth/usage",
() =>
jsonResponse({
...(hasWindows ? makeClaudeResponse() : {}),
extra_usage: {
is_enabled: true,
monthly_limit: 5000,
used_credits: 1250,
currency: "USD",
},
}),
],
]),
);
const claude = findProvider(await service().listUsage(), "claude");
expect(claude.balances).toEqual([
expect.objectContaining({
id: "extra_usage_monthly",
used: 12.5,
remaining: 37.5,
limit: 50,
unit: "usd",
}),
]);
expect(claude.details).toContainEqual(
expect.objectContaining({ id: "extra_usage", value: "Enabled" }),
);
});
it.each(["EUR", null, undefined])(
"does not report extra usage balances for currency %j",
async (currency) => {
writeClaudeCredentials(claudeHome, "at_valid");
fetchApi = mockFetch(
new Map([
[
"https://api.anthropic.com/api/oauth/usage",
() =>
jsonResponse({
...makeClaudeResponse(),
extra_usage: { monthly_limit: 5000, used_credits: 1250, currency },
}),
],
]),
);
const claude = findProvider(await service().listUsage(), "claude");
expect(claude.balances).toEqual([]);
},
);
it("accepts a null Claude resets_at when a window has no scheduled reset", async () => {
writeClaudeCredentials(claudeHome, "at_valid");
+4 -28
View File
@@ -68,9 +68,6 @@ const ClaudeUsageResponseSchema = z.object({
extra_usage: z
.object({
is_enabled: z.boolean().optional(),
monthly_limit: ApiNumberSchema.nullish(),
used_credits: ApiNumberSchema.nullish(),
currency: z.string().nullish(),
})
.nullish(),
});
@@ -494,26 +491,6 @@ function toCredentialRecord(credentials: ClaudeCredentials): ClaudeCredentialRec
: null;
}
function extraUsageBalances(extraUsage: ClaudeUsageResponse["extra_usage"]) {
if (extraUsage?.currency?.toUpperCase() !== "USD") return [];
const used = extraUsage.used_credits == null ? undefined : extraUsage.used_credits / 100;
const limit = extraUsage.monthly_limit == null ? undefined : extraUsage.monthly_limit / 100;
if (used == null && limit == null) return [];
return [
{
id: "extra_usage_monthly",
label: "Extra usage",
used,
remaining: used != null && limit != null ? Math.max(0, limit - used) : undefined,
limit,
unit: "usd" as const,
tone: toneFromUsedPct(
used != null && limit != null && limit > 0 ? (used / limit) * 100 : null,
),
},
];
}
export async function fetchUsage(
input: UsageInput,
fetchApi: typeof fetch = fetch,
@@ -617,21 +594,20 @@ export async function fetchUsage(
}
const details: UsageDetail[] = [];
const extraUsage = resp.extra_usage;
if (extraUsage?.is_enabled !== undefined) {
const extraUsageEnabled = resp.extra_usage?.is_enabled;
if (extraUsageEnabled !== undefined) {
details.push({
id: "extra_usage",
label: "Extra usage",
value: extraUsage.is_enabled ? "Enabled" : "Disabled",
value: extraUsageEnabled ? "Enabled" : "Disabled",
});
}
const balances = extraUsageBalances(extraUsage);
return {
status: "available",
planLabel: plan ?? undefined,
windows,
balances,
balances: [],
details,
};
}
@@ -117,56 +117,6 @@ test("coerces credit balance and marks a 96 percent window dangerous", async ()
]);
});
test("reports enterprise individual spend limit percentage and reset", async () => {
const report = await fetchUsage(authInput(fixtureHome), async () =>
Response.json({
plan_type: "enterprise",
spend_control: {
individual_limit: { used_percent: 5, remaining_percent: 95, reset_at: 1795996800 },
},
}),
);
expect(report).toMatchObject({
planLabel: "enterprise",
windows: [
expect.objectContaining({
id: "individual_limit",
label: "Individual spend limit",
summary: true,
usedPct: 5,
remainingPct: 95,
resetsAt: new Date(1795996800 * 1000).toISOString(),
}),
],
});
});
test.each([
[{ used_percent: 5 }, 5, 95],
[{ remaining_percent: 95 }, 5, 95],
[{ used_percent: 5, remaining_percent: 90 }, 5, 90],
[{ used_percent: 110 }, 110, 0],
])(
"completes individual spending percentages for %j",
async (individual_limit, usedPct, remainingPct) => {
const report = await fetchUsage(authInput(fixtureHome), async () =>
Response.json({ spend_control: { individual_limit } }),
);
expect(report).toMatchObject({
status: "available",
windows: [expect.objectContaining({ id: "individual_limit", usedPct, remainingPct })],
});
},
);
test("omits individual spend limit without a percentage", async () => {
const report = await fetchUsage(authInput(fixtureHome), async () =>
Response.json({ spend_control: { individual_limit: { reset_at: 1795996800 } } }),
);
expect(report.windows).toEqual([]);
});
test("summarizes the session and weekly windows by default, not code review", async () => {
const report = await fetchUsage(
authInput(fixtureHome),
@@ -34,15 +34,6 @@ const rateLimitSchema = z.object({
primary_window: windowSchema.nullish(),
secondary_window: windowSchema.nullish(),
});
const spendControlSchema = z.object({
individual_limit: z
.object({
used_percent: number.nullish(),
remaining_percent: number.nullish(),
reset_at: number.nullish(),
})
.nullish(),
});
const responseSchema = z.object({
plan_type: z.string().optional(),
email: z.string().optional(),
@@ -57,7 +48,6 @@ const responseSchema = z.object({
)
.nullish(),
code_review_rate_limit: rateLimitSchema.nullish(),
spend_control: spendControlSchema.nullish(),
credits: z.object({ balance: number.optional() }).nullish(),
});
@@ -237,27 +227,6 @@ export async function fetchUsage(
},
});
}),
...(usage.spend_control?.individual_limit
? (() => {
const limit = usage.spend_control.individual_limit;
const usedPct =
limit.used_percent ??
(limit.remaining_percent != null ? 100 - limit.remaining_percent : undefined);
if (usedPct === undefined) return [];
return [
{
id: "individual_limit",
label: "Individual spend limit",
summary: true,
usedPct,
remainingPct: limit.remaining_percent ?? Math.max(0, 100 - usedPct),
resetsAt:
limit.reset_at != null ? new Date(limit.reset_at * 1000).toISOString() : null,
tone: toneFromUsedPct(usedPct),
},
];
})()
: []),
...usageWindows({
rateLimit: usage.code_review_rate_limit,
scope: { id: "code_review", label: "Code review" },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.15.3",
"version": "0.15.2",
"private": true,
"type": "module",
"scripts": {
-2
View File
@@ -211,8 +211,6 @@ paseo workspace rename <workspace-id> --reset # back to the branch or director
paseo workspace archive <workspace-id>
```
Inside an agent session, `PASEO_WORKSPACE_ID` holds the agent's own workspace ID, so an agent can run `paseo workspace rename "$PASEO_WORKSPACE_ID" "Auth rework"` or `paseo workspace archive "$PASEO_WORKSPACE_ID"` even when other workspaces share its directory.
Add `--forge <name>` to PR checkout when Paseo cannot infer the forge from the source checkout. See [Git worktrees](/docs/worktrees) for setup hooks and services.
## Terminals
-2
View File
@@ -202,8 +202,6 @@ paseo workspace rename <workspace-id> --reset # back to the branch or director
paseo workspace archive <workspace-id>
```
在 Agent 会话中,`PASEO_WORKSPACE_ID` 保存该 Agent 自己的工作区 ID。即使其他工作区共享同一个目录,Agent 也可以运行 `paseo workspace rename "$PASEO_WORKSPACE_ID" "Auth rework"` 或 `paseo workspace archive "$PASEO_WORKSPACE_ID"`。
当 Paseo 无法从源检出推断代码托管平台时,为 PR 检出添加 `--forge <name>`。setup 钩子和服务见 [Git worktree](/docs/worktrees)。
## 终端 {#terminals}
+1 -1
View File
@@ -598,7 +598,7 @@ Creation request
→ resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers)
→ set PASEO_AGENT_ID, PASEO_AGENT_CWD, and PASEO_WORKSPACE_ID when the agent has a workspace
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ open provider session and save agent configuration
```
+1 -1
View File
@@ -581,7 +581,7 @@ Creation request
→ resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers)
→ set PASEO_AGENT_ID, PASEO_AGENT_CWD, and PASEO_WORKSPACE_ID when the agent has a workspace
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ open provider session and save agent configuration
```