(compact: T, desktop: T) {
+ return { xs: compact, md: desktop };
+}
+
+export const density = {
+ /** A list row such as a sidebar entry. */
+ rowHeight: byLayout(44, 36),
+ /** A row in a tight group, such as the sidebar's navigation rows. */
+ tightRowHeight: byLayout(40, 28),
+ /** The line height of a row's `base` text; slots that align to its first line share it. */
+ rowLineHeight: byLayout(22, 20),
+ /** A square icon-only button that sits in a row or toolbar. */
+ iconButton: byLayout(44, 28),
+};
+
+/** The width or height of a slot that holds an icon of `size`. */
+export function densityIconBox(size: DensityIconSize) {
+ return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]);
+}
+
+export function densityFontSize(theme: Theme, size: TextSize) {
+ return byLayout(theme.fontSize[COMPACT_TEXT_SIZE[size]], theme.fontSize[size]);
+}
+
+export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number {
+ return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size];
+}
+
+/** Extends a `withUnistyles` icon mapping so the icon also takes its density size. */
+export function densityIconProps(
+ size: DensityIconSize,
+ mapping: (theme: Theme) => P,
+) {
+ return (theme: Theme, rt: { breakpoint?: string }) => ({
+ ...mapping(theme),
+ size: densityIconSize(rt, size),
+ });
+}
diff --git a/packages/app/src/styles/theme.test.ts b/packages/app/src/styles/theme.test.ts
index 707085e7c9..461d980ecd 100644
--- a/packages/app/src/styles/theme.test.ts
+++ b/packages/app/src/styles/theme.test.ts
@@ -68,9 +68,9 @@ describe("Pure black theme", () => {
});
describe("Sidebar interaction surfaces", () => {
- it("keeps Light selection distinct from the sidebar surface", () => {
- expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface1);
- expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface3);
+ it("keeps Light hover and selection darker than the sidebar surface", () => {
+ expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface3);
+ expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface4);
expect(lightTheme.colors.surfaceSidebarSelected).not.toBe(lightTheme.colors.surfaceSidebar);
});
diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts
index 04b1e8f6ae..8e1e77aac7 100644
--- a/packages/app/src/styles/theme.ts
+++ b/packages/app/src/styles/theme.ts
@@ -267,8 +267,8 @@ export function buildLightSemanticColors(tint: LightThemeConfig) {
surface4: tint.surface4,
surfaceDiffEmpty: tint.surfaceDiffEmpty,
surfaceSidebar: tint.surfaceSidebar,
- surfaceSidebarHover: tint.surface1,
- surfaceSidebarSelected: tint.surface3,
+ surfaceSidebarHover: tint.surface3,
+ surfaceSidebarSelected: tint.surface4,
surfaceWorkspace: tint.surface0,
interactionHighlight: "rgba(0, 0, 0, 0.06)",
diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts
index 96badfd354..01d6880d5b 100644
--- a/packages/app/src/subagents/provider-store.test.ts
+++ b/packages/app/src/subagents/provider-store.test.ts
@@ -1,8 +1,11 @@
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, test } from "vitest";
+import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
import {
observeProviderSubagentTimeline,
providerSubagentKey,
+ refreshProviderSubagents,
+ resyncProviderSubagents,
useProviderSubagentStore,
} from "./provider-store";
@@ -15,6 +18,7 @@ afterEach(() => {
descriptors: new Map(),
timelines: new Map(),
hiddenFromTrack: new Set(),
+ trackedParents: new Set(),
});
});
@@ -727,3 +731,96 @@ describe("projected child history", () => {
expect(current().lastSeq).toBe(2000);
});
});
+
+describe("resyncing after the update feed resubscribes", () => {
+ function child(
+ id: string,
+ parentAgentId: string,
+ status: ProviderSubagentDescriptorPayload["status"],
+ ): ProviderSubagentDescriptorPayload {
+ return {
+ id,
+ parentAgentId,
+ provider: "claude",
+ title: "Explore",
+ description: null,
+ status,
+ createdAt: "2026-10-07T10:00:00.000Z",
+ updatedAt: "2026-10-07T10:00:00.000Z",
+ toolCallId: id,
+ };
+ }
+
+ function fakeDaemon(lists: Record) {
+ const requested: string[] = [];
+ return {
+ requested,
+ lists,
+ client: {
+ listProviderSubagents: async (parentAgentId: string) => {
+ requested.push(parentAgentId);
+ return {
+ requestId: "list",
+ parentAgentId,
+ subagents: lists[parentAgentId] ?? [],
+ error: null,
+ };
+ },
+ },
+ };
+ }
+
+ const status = (parentAgentId: string, subagentId: string) =>
+ useProviderSubagentStore
+ .getState()
+ .descriptors.get(providerSubagentKey(SERVER_ID, parentAgentId, subagentId))?.status;
+
+ test("catches up on children that finished or started while updates were not delivered", async () => {
+ const daemon = fakeDaemon({ [PARENT_ID]: [child("child-1", PARENT_ID, "running")] });
+ await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID);
+ expect(status(PARENT_ID, "child-1")).toBe("running");
+
+ // The connection dropped: the daemon finished child-1 and ran child-2 without telling us.
+ daemon.lists[PARENT_ID] = [
+ child("child-1", PARENT_ID, "completed"),
+ child("child-2", PARENT_ID, "completed"),
+ ];
+ await resyncProviderSubagents(daemon.client, SERVER_ID);
+
+ expect(status(PARENT_ID, "child-1")).toBe("completed");
+ expect(status(PARENT_ID, "child-2")).toBe("completed");
+ });
+
+ test("catches up on a parent whose first list failed while disconnected", async () => {
+ const daemon = fakeDaemon({});
+ const disconnected = {
+ listProviderSubagents: async () => {
+ throw new DaemonConnectionError("Transport not connected");
+ },
+ };
+ await expect(
+ refreshProviderSubagents(disconnected, SERVER_ID, PARENT_ID),
+ ).rejects.toBeInstanceOf(DaemonConnectionError);
+ useProviderSubagentStore.getState().applyUpdate(SERVER_ID, {
+ kind: "upsert",
+ subagent: child("child-1", PARENT_ID, "running"),
+ });
+
+ // The connection dropped again: the daemon finished child-1 without telling us.
+ daemon.lists[PARENT_ID] = [child("child-1", PARENT_ID, "completed")];
+ await resyncProviderSubagents(daemon.client, SERVER_ID);
+
+ expect(status(PARENT_ID, "child-1")).toBe("completed");
+ });
+
+ test("lists again only the parents this server was showing", async () => {
+ const daemon = fakeDaemon({});
+ await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID);
+ await refreshProviderSubagents(daemon.client, "server-2", "other-parent");
+ daemon.requested.length = 0;
+
+ await resyncProviderSubagents(daemon.client, SERVER_ID);
+
+ expect(daemon.requested).toEqual([PARENT_ID]);
+ });
+});
diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts
index 86e857d774..fd44dae8d2 100644
--- a/packages/app/src/subagents/provider-store.ts
+++ b/packages/app/src/subagents/provider-store.ts
@@ -28,6 +28,9 @@ interface ProviderSubagentState {
descriptors: Map;
timelines: Map;
hiddenFromTrack: Set;
+ /** Parents whose list this client asked for, so a resubscribed update feed can catch them up. */
+ trackedParents: Set;
+ trackParent(serverId: string, parentAgentId: string): void;
hideFromTrack(serverId: string, parentAgentId: string, subagentIds: readonly string[]): void;
replaceList(
serverId: string,
@@ -84,6 +87,7 @@ export function refreshProviderSubagents(
const pending = clientRequests.get(requestKey);
if (pending) return pending;
+ useProviderSubagentStore.getState().trackParent(serverId, parentAgentId);
const request = client
.listProviderSubagents(parentAgentId)
.then((payload) => {
@@ -101,6 +105,27 @@ function parentPrefix(serverId: string, parentAgentId: string): string {
return `${serverId}\0${parentAgentId}\0`;
}
+/**
+ * Updates are not replayed across a reconnect, so a child that finished or started while the
+ * update feed was down stays stale until its parent is listed again. Call this once the feed has
+ * resubscribed, so every update after the list is delivered.
+ */
+export async function resyncProviderSubagents(
+ client: ProviderSubagentListClient,
+ serverId: string,
+): Promise {
+ const serverPrefix = `${serverId}\0`;
+ const parentAgentIds = [...useProviderSubagentStore.getState().trackedParents]
+ .filter((key) => key.startsWith(serverPrefix))
+ .map((key) => key.slice(serverPrefix.length, -1));
+ await Promise.all(
+ parentAgentIds.map((parentAgentId) =>
+ // A parent that was archived meanwhile has nothing left to catch up.
+ refreshProviderSubagents(client, serverId, parentAgentId).catch(() => undefined),
+ ),
+ );
+}
+
const EMPTY_TIMELINE: ProviderSubagentTimelineState = {
tail: [],
head: [],
@@ -147,6 +172,14 @@ export const useProviderSubagentStore = create((set) => (
descriptors: new Map(),
timelines: new Map(),
hiddenFromTrack: new Set(),
+ trackedParents: new Set(),
+ trackParent(serverId, parentAgentId) {
+ set((state) => {
+ const prefix = parentPrefix(serverId, parentAgentId);
+ if (state.trackedParents.has(prefix)) return state;
+ return { trackedParents: new Set(state.trackedParents).add(prefix) };
+ });
+ },
hideFromTrack(serverId, parentAgentId, subagentIds) {
set((state) => {
const hiddenFromTrack = new Set(state.hiddenFromTrack);
diff --git a/packages/app/src/tool-calls/presentation.test.ts b/packages/app/src/tool-calls/presentation.test.ts
index 3caf235ace..fde0ba020f 100644
--- a/packages/app/src/tool-calls/presentation.test.ts
+++ b/packages/app/src/tool-calls/presentation.test.ts
@@ -92,3 +92,42 @@ describe("tool-call presentation", () => {
expect(presentation.icon).toBe(fakeIcons.brain);
});
});
+
+it.each([
+ ["message", "Message from QA messenger"],
+ ["finished", "QA messenger finished"],
+ ["errored", "QA messenger errored"],
+ ["permission-required", "QA messenger needs permission"],
+ ["closed", "QA messenger closed"],
+] as const)("presents %s from structured provenance without a second label", (event, heading) => {
+ const presentation = buildToolCallPresentation({
+ toolName: "internal_name_should_not_be_visible",
+ status: "completed",
+ error: null,
+ detail: { type: "plain_text", label: "legacy summary", text: "legacy body" },
+ agentMessage: {
+ event,
+ sender: { id: "server::sender", title: "QA messenger" },
+ text: "The delivered message.",
+ },
+ resolveIcon: fakeResolveIcon,
+ });
+ expect(presentation.displayName).toBe(heading);
+ expect(presentation.summary).toBeUndefined();
+ expect(presentation.detail).toEqual({
+ type: "plain_text",
+ icon: "bot",
+ text: "From agent: server::sender\n\nThe delivered message.",
+ });
+});
+
+it("uses an opaque sender ID when no title is available", () => {
+ const presentation = buildToolCallPresentation({
+ toolName: "agent_message",
+ status: "completed",
+ error: null,
+ agentMessage: { event: "message", sender: { id: "remote::worker" }, text: "Hello" },
+ resolveIcon: fakeResolveIcon,
+ });
+ expect(presentation.displayName).toBe("Message from remote::worker");
+});
diff --git a/packages/app/src/tool-calls/presentation.ts b/packages/app/src/tool-calls/presentation.ts
index 3db13ccbbd..f25d752624 100644
--- a/packages/app/src/tool-calls/presentation.ts
+++ b/packages/app/src/tool-calls/presentation.ts
@@ -1,3 +1,5 @@
+import type { AgentMessage } from "@getpaseo/protocol/agent-message";
+import { buildAgentMessageDisplay } from "@getpaseo/protocol/agent-message-display";
import type { PlanOutcome } from "@/components/plan-card";
import type { ComponentType } from "react";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
@@ -13,6 +15,7 @@ type ToolCallStatus = "executing" | "running" | "completed" | "failed" | "cancel
export type ToolCallPresentationIcon = ComponentType<{ size?: number; color?: string }>;
interface BuildToolCallPresentationInput {
+ agentMessage?: AgentMessage;
toolName: string;
status: ToolCallStatus;
error: unknown;
@@ -23,6 +26,7 @@ interface BuildToolCallPresentationInput {
}
export interface ToolCallPresentation {
+ detail: ToolCallDetail | undefined;
displayName: string;
summary?: string;
errorText?: string;
@@ -51,9 +55,13 @@ function displayDetail(detail: ToolCallDetail | undefined): ToolCallDetail {
export function buildToolCallPresentation(
input: BuildToolCallPresentationInput,
): ToolCallPresentation {
- const detailForDisplay = displayDetail(input.detail);
+ const detail = input.agentMessage
+ ? buildAgentMessageDisplay(input.agentMessage).detail
+ : input.detail;
+ const detailForDisplay = displayDetail(detail);
const displayModel = buildToolCallDisplayModel({
name: input.toolName,
+ agentMessage: input.agentMessage,
status: displayStatus(input.status),
error: input.error ?? null,
detail: detailForDisplay,
@@ -61,21 +69,22 @@ export function buildToolCallPresentation(
cwd: input.cwd,
});
const isLoadingDetails = isPendingToolCallDetail({
- detail: input.detail,
+ detail,
status: input.status,
error: input.error,
});
- const hasDetails = Boolean(input.error) || hasMeaningfulToolCallDetail(input.detail);
+ const hasDetails = Boolean(input.error) || hasMeaningfulToolCallDetail(detail);
return {
+ detail,
displayName: displayModel.displayName,
summary: displayModel.summary,
errorText: displayModel.errorText,
- icon: input.resolveIcon(input.toolName, input.detail),
+ icon: input.resolveIcon(input.toolName, detail),
isLoadingDetails,
hasDetails,
canOpenDetails: hasDetails || isLoadingDetails,
- openFilePath: extractToolCallFilePath(input.detail),
+ openFilePath: extractToolCallFilePath(detail),
isPlan: input.detail?.type === "plan",
planOutcome: input.detail?.type === "plan" ? resolvePlanOutcome(input) : undefined,
};
diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts
index f33a9c524b..62e26698db 100644
--- a/packages/app/src/types/stream.test.ts
+++ b/packages/app/src/types/stream.test.ts
@@ -2305,3 +2305,28 @@ describe("notification timeline items", () => {
expect(new Set(state.map((item) => item.id)).size).toBe(state.length);
});
});
+
+it("retains agent-message provenance through live delivery and repeated history", () => {
+ const agentMessage = {
+ event: "finished" as const,
+ sender: { id: "remote::worker", title: "Reviewer" },
+ text: "Done",
+ };
+ const event = {
+ type: "timeline",
+ provider: "codex",
+ item: {
+ type: "tool_call",
+ callId: "delivery",
+ name: "agent_message",
+ status: "completed",
+ error: null,
+ detail: { type: "plain_text", text: "Done" },
+ agentMessage,
+ },
+ } satisfies AgentStreamEventPayload;
+ const live = reduceStreamUpdate([], event, new Date(1));
+ const replay = reduceStreamUpdate(live, event, new Date(2));
+ expect(replay).toHaveLength(1);
+ expect(replay[0]).toMatchObject({ payload: { data: { agentMessage } } });
+});
diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts
index 78789fa2d8..56cf14558b 100644
--- a/packages/app/src/types/stream.ts
+++ b/packages/app/src/types/stream.ts
@@ -1,3 +1,4 @@
+import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type {
AgentProvider,
AgentTimelineItem,
@@ -745,6 +746,7 @@ interface OrchestratorToolCallData {
}
export interface AgentToolCallData {
+ agentMessage?: AgentMessage;
provider: AgentProvider;
callId: string;
name: string;
@@ -802,6 +804,8 @@ export interface CompactionItem {
export interface PluginTimelineStreamItem {
kind: "plugin";
id: string;
+ /** The id of the stream item a timeline transformer replaced; set only by projection. */
+ sourceId?: string;
timelineCursor?: TimelinePosition;
turnId?: string;
timestamp: Date;
@@ -1446,6 +1450,7 @@ function reduceTimelineToolCall(
error: item.error,
detail: item.detail,
metadata: item.metadata,
+ ...(item.agentMessage ? { agentMessage: item.agentMessage } : {}),
},
timestamp,
timelineCursor,
diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx
index 020faf61ec..20297c9df0 100644
--- a/packages/app/src/usage/controls.tsx
+++ b/packages/app/src/usage/controls.tsx
@@ -31,7 +31,7 @@ export function UsageControls({
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
return (
- {hostSelection && hostSelection.hosts.length > 1 ? (
+ {hasHostChoice(hostSelection) ? (
1;
+}
+
function usageHostOptionTestID(serverId: string): string {
return `usage-host-filter-item-${serverId}`;
}
@@ -65,17 +71,22 @@ const styles = StyleSheet.create((theme) => ({
}));
/**
- * One host's usage and the title-row controls that go with it, for the Usage modal.
+ * One host’s usage and title-row controls for the Usage modal.
+ * Returns null when there is nothing to control, so headers leave no empty island.
*/
export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
view: UsageView;
refresh: () => void;
- controls: ReactElement;
+ controls: ReactElement | null;
} {
const { view, refresh } = useHostUsage(hostSelection.serverId);
+ const hasControls = hasHostChoice(hostSelection) || view.kind !== "unavailable";
const controls = useMemo(
- () => ,
- [hostSelection, refresh, view],
+ () =>
+ hasControls ? (
+
+ ) : null,
+ [hasControls, hostSelection, refresh, view],
);
return { view, refresh, controls };
}
diff --git a/packages/app/src/usage/refresh-button.tsx b/packages/app/src/usage/refresh-button.tsx
index bf6dc787b2..48754c4e19 100644
--- a/packages/app/src/usage/refresh-button.tsx
+++ b/packages/app/src/usage/refresh-button.tsx
@@ -9,7 +9,10 @@ import { usageCopy } from "./copy";
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
-/** Refreshes every report on the host; spins while a refresh is in flight. */
+/**
+ * Refreshes every report on the host; spins while a refresh is in flight. On compact it sits in the
+ * header's island, so its highlight is round.
+ */
export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) {
const compact = useIsCompactFormFactor();
const iconSize = paneContentToolbarIconSize(compact);
diff --git a/packages/app/src/utils/file-mention-autocomplete.test.ts b/packages/app/src/utils/file-mention-autocomplete.test.ts
index 4f818e25e2..c74c821522 100644
--- a/packages/app/src/utils/file-mention-autocomplete.test.ts
+++ b/packages/app/src/utils/file-mention-autocomplete.test.ts
@@ -48,6 +48,74 @@ describe("findActiveFileMention", () => {
});
expect(mention).toBeNull();
});
+
+ it("returns null for the issue #1364 angle-bracket email example", () => {
+ const text = "user <12345+user@noreply.example.com>";
+ const mention = findActiveFileMention({
+ text,
+ cursorIndex: text.length,
+ });
+ expect(mention).toBeNull();
+ });
+
+ it("returns null for email addresses (word char before @)", () => {
+ const text = "alice@example.com";
+ const mention = findActiveFileMention({
+ text,
+ cursorIndex: text.length,
+ });
+ expect(mention).toBeNull();
+ });
+
+ it("returns null for email addresses (+ immediately before @)", () => {
+ // Local part ends with + so the character at index-1 is '+', not a word char.
+ const text = "noreply+@example.com";
+ const mention = findActiveFileMention({
+ text,
+ cursorIndex: text.length,
+ });
+ expect(mention).toBeNull();
+ });
+
+ it("returns null for plus-tagged email local parts", () => {
+ const text = "noreply+tag@example.com";
+ const mention = findActiveFileMention({
+ text,
+ cursorIndex: text.length,
+ });
+ expect(mention).toBeNull();
+ });
+
+ it("returns null when query contains angle brackets", () => {
+ const text = "check @foo end";
+ const mention = findActiveFileMention({
+ text,
+ cursorIndex: text.indexOf(" end"),
+ });
+ expect(mention).toBeNull();
+ });
+
+ it("returns null when query ends with a closing angle bracket", () => {
+ const text = "check @foo>";
+ const mention = findActiveFileMention({
+ text,
+ cursorIndex: text.length,
+ });
+ expect(mention).toBeNull();
+ });
+
+ it("still detects a file mention after an email in the same input", () => {
+ const text = "user <12345+user@noreply.example.com> and also @file";
+ const mention = findActiveFileMention({
+ text,
+ cursorIndex: text.length,
+ });
+ expect(mention).toEqual({
+ start: text.lastIndexOf("@"),
+ end: text.length,
+ query: "file",
+ });
+ });
});
describe("formatQuotedFileMentionPath", () => {
diff --git a/packages/app/src/utils/file-mention-autocomplete.ts b/packages/app/src/utils/file-mention-autocomplete.ts
index d19424cfe4..fe09b52f03 100644
--- a/packages/app/src/utils/file-mention-autocomplete.ts
+++ b/packages/app/src/utils/file-mention-autocomplete.ts
@@ -15,7 +15,7 @@ interface ApplyFileMentionReplacementInput {
relativePath: string;
}
-const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/;
+const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"'<>]/;
export function findActiveFileMention(input: FindActiveFileMentionInput): FileMentionRange | null {
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length));
@@ -26,6 +26,11 @@ export function findActiveFileMention(input: FindActiveFileMentionInput): FileMe
atIndex >= 0;
atIndex = atIndex === 0 ? -1 : beforeCursor.lastIndexOf("@", atIndex - 1)
) {
+ // Skip @ that looks like part of an email address (local-part char immediately before @).
+ if (atIndex > 0 && /[\w+]/.test(beforeCursor[atIndex - 1]!)) {
+ continue;
+ }
+
const query = beforeCursor.slice(atIndex + 1);
if (INVALID_MENTION_QUERY_CHARS.test(query)) {
continue;
diff --git a/packages/app/src/utils/workspace-directory.test.ts b/packages/app/src/utils/workspace-directory.test.ts
index b40bdd9705..b3973fb7b6 100644
--- a/packages/app/src/utils/workspace-directory.test.ts
+++ b/packages/app/src/utils/workspace-directory.test.ts
@@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "./workspace-directory";
describe("resolveWorkspaceDirectory", () => {
+ it("preserves significant whitespace in a workspace folder name", () => {
+ expect(resolveWorkspaceDirectory({ workspaceDirectory: "/repo " })).toBe("/repo ");
+ expect(requireWorkspaceDirectory({ workspaceDirectory: "/repo /" })).toBe("/repo ");
+ });
it("canonicalizes a workspace directory and returns null when blank", () => {
expect(resolveWorkspaceDirectory({ workspaceDirectory: "C:\\repo\\app\\" })).toBe(
"C:/repo/app",
diff --git a/packages/app/src/utils/workspace-identity.ts b/packages/app/src/utils/workspace-identity.ts
index 8c778372d9..3cf3134939 100644
--- a/packages/app/src/utils/workspace-identity.ts
+++ b/packages/app/src/utils/workspace-identity.ts
@@ -13,11 +13,10 @@ export function normalizeWorkspaceOpaqueId(value: string | null | undefined): st
}
export function normalizeWorkspacePath(value: string | null | undefined): string | null {
- const trimmed = trimNonEmpty(value);
- if (!trimmed) {
+ if (typeof value !== "string" || value.trim().length === 0) {
return null;
}
- const withUnixSeparators = trimmed.replace(/\\/g, "/");
+ const withUnixSeparators = value.replace(/\\/g, "/");
if (withUnixSeparators === "/") {
return withUnixSeparators;
}
diff --git a/packages/app/src/workspace-labels/manager-modal.tsx b/packages/app/src/workspace-labels/manager-modal.tsx
index 0dc8f1c53f..a23e7e0925 100644
--- a/packages/app/src/workspace-labels/manager-modal.tsx
+++ b/packages/app/src/workspace-labels/manager-modal.tsx
@@ -174,9 +174,6 @@ export function WorkspaceLabelManagerModal({
onClose={onClose}
header={header}
footer={footer}
- // Bound the compact scroller to the live snap height, so the edit view's footer stays on
- // screen instead of being pushed past the bottom of the sheet.
- sizeContentToCurrentSnapPoint
testID="workspace-label-manager"
>
{draft && editing ? (
diff --git a/packages/app/src/workspace-tabs/compact-sheet.ts b/packages/app/src/workspace-tabs/compact-sheet.ts
new file mode 100644
index 0000000000..947d4904c5
--- /dev/null
+++ b/packages/app/src/workspace-tabs/compact-sheet.ts
@@ -0,0 +1,38 @@
+import { getWorkspacePanelAttributes } from "@/panels/panel-instance-attributes";
+import { panelCompactSheetPolicy, type CompactSheetPolicy } from "@/panels/panel-manifest";
+import { useSessionStore, type Agent } from "@/stores/session-store";
+import { isWorkspaceRootAgent } from "@/subagents/policies";
+import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
+
+function workspaceAgentLookup(workspaceKey: string): (agentId: string) => Agent | null {
+ const sessions = useSessionStore.getState().sessions;
+ const serverId = Object.keys(sessions)
+ .filter((candidate) => workspaceKey.startsWith(`${candidate}:`))
+ .sort((left, right) => right.length - left.length)[0];
+ const session = serverId ? sessions[serverId] : undefined;
+ return (agentId) => session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
+}
+
+/**
+ * The single target-aware boundary deciding whether a compact reveal lands in the workspace
+ * sheet. An agent the client has not loaded yet counts as a root agent and stays a tab.
+ */
+export function resolveCompactSheetPolicy(
+ workspaceKey: string,
+ target: WorkspaceTabTarget,
+): CompactSheetPolicy | null {
+ return panelCompactSheetPolicy(target, {
+ isWorkspaceRootAgent: (agentId) => {
+ const getAgent = workspaceAgentLookup(workspaceKey);
+ const agent = getAgent(agentId);
+ if (!agent) return true;
+ const parent = agent.parentAgentId ? (getAgent(agent.parentAgentId) ?? undefined) : undefined;
+ return isWorkspaceRootAgent(agent, parent);
+ },
+ });
+}
+
+/** Whether the sheet's tab holds unsaved work, which must never be discarded with the sheet. */
+export function isSheetTabModified(workspaceKey: string, tabId: string): boolean {
+ return getWorkspacePanelAttributes(workspaceKey, tabId).modified;
+}
diff --git a/packages/app/src/workspace-tabs/layout-mode-publisher.tsx b/packages/app/src/workspace-tabs/layout-mode-publisher.tsx
new file mode 100644
index 0000000000..03df9b695a
--- /dev/null
+++ b/packages/app/src/workspace-tabs/layout-mode-publisher.tsx
@@ -0,0 +1,21 @@
+import { useLayoutEffect } from "react";
+import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
+import { useWorkspaceLayoutStore, type WorkspaceLayoutMode } from "@/stores/workspace-layout-store";
+
+function resolveWorkspaceLayoutMode(isCompact: boolean): WorkspaceLayoutMode {
+ if (isCompact) return "compact";
+ return supportsDesktopPaneSplits() ? "split-panes" : "single-pane";
+}
+
+/**
+ * Publishes the shell's workspace layout to the layout store, which decides where opened targets
+ * land. Render it before the routes: React runs an earlier sibling's layout effects before a later
+ * sibling's subtree, so a route sees the mode even from its first layout effect.
+ */
+export function WorkspaceLayoutModePublisher(): null {
+ const isCompact = useIsCompactFormFactor();
+ useLayoutEffect(() => {
+ useWorkspaceLayoutStore.getState().setLayoutMode(resolveWorkspaceLayoutMode(isCompact));
+ }, [isCompact]);
+ return null;
+}
diff --git a/packages/app/src/workspace-tabs/open-beside.test.ts b/packages/app/src/workspace-tabs/open-beside.test.ts
new file mode 100644
index 0000000000..ac181eeb75
--- /dev/null
+++ b/packages/app/src/workspace-tabs/open-beside.test.ts
@@ -0,0 +1,177 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@react-native-async-storage/async-storage", () => ({
+ default: {
+ getItem: vi.fn(async () => null),
+ setItem: vi.fn(async () => undefined),
+ removeItem: vi.fn(async () => undefined),
+ },
+}));
+
+import { DEFAULT_APP_SETTINGS } from "@/hooks/use-settings";
+import { setPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
+import {
+ collectAllPanes,
+ collectAllTabs,
+ findPaneById,
+ useWorkspaceLayoutStore,
+} from "@/stores/workspace-layout-store";
+import {
+ openPreferredWorkspacePreview,
+ openPreferredWorkspaceTarget,
+ openWorkspaceTargetBeside,
+} from "@/workspace-tabs/open-beside";
+
+const WORKSPACE_KEY = "server-1:workspace-1";
+
+beforeEach(() => {
+ useWorkspaceLayoutStore.setState({
+ layoutMode: "split-panes",
+ sheetByWorkspace: {},
+ layoutByWorkspace: {},
+ explorerSidebarPaneIdByWorkspace: {},
+ sidePaneIdByWorkspace: {},
+ });
+});
+
+describe("openPreferredWorkspacePreview", () => {
+ it("opens a compact file preview in the sheet instead of replacing the active file tab", () => {
+ const store = useWorkspaceLayoutStore.getState();
+ const fileTabId = store.openTab({
+ workspaceKey: WORKSPACE_KEY,
+ target: { kind: "file", path: "src/open.ts" },
+ intent: "reveal",
+ });
+ store.setLayoutMode("compact");
+
+ const tabId = openPreferredWorkspacePreview({
+ workspaceKey: WORKSPACE_KEY,
+ serverId: "server-1",
+ workspaceId: "workspace-1",
+ explorerSidebarPaneId: "explorer",
+ lastMainPaneId: "main",
+ target: { kind: "file", path: "src/preview.ts" },
+ source: "explorerFiles",
+ preferences: DEFAULT_APP_SETTINGS.openInSidePane,
+ });
+
+ const state = useWorkspaceLayoutStore.getState();
+ expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab).toMatchObject({
+ tabId,
+ target: { kind: "file", path: "src/preview.ts" },
+ });
+ const root = state.layoutByWorkspace[WORKSPACE_KEY].root;
+ expect(findPaneById(root, "main")?.tabIds).toEqual([fileTabId]);
+ expect(collectAllTabs(root).find((tab) => tab.tabId === fileTabId)?.target).toEqual({
+ kind: "file",
+ path: "src/open.ts",
+ });
+ });
+});
+
+describe("previews under a sheet with unsaved work", () => {
+ const sheetTarget = { kind: "file" as const, path: "src/draft.ts" };
+ let sheetTabId: string | null = null;
+
+ afterEach(() => {
+ if (sheetTabId) {
+ setPanelInstanceAttributes(
+ { serverId: "server-1", workspaceId: "workspace-1", tabId: sheetTabId },
+ { modified: false },
+ );
+ }
+ sheetTabId = null;
+ });
+
+ function previewFile(path: string): string | null {
+ return openPreferredWorkspacePreview({
+ workspaceKey: WORKSPACE_KEY,
+ serverId: "server-1",
+ workspaceId: "workspace-1",
+ explorerSidebarPaneId: "explorer",
+ lastMainPaneId: "main",
+ target: { kind: "file", path },
+ source: "explorerFiles",
+ preferences: DEFAULT_APP_SETTINGS.openInSidePane,
+ });
+ }
+
+ it("reuses one preview tab instead of adding a tab per link", () => {
+ const store = useWorkspaceLayoutStore.getState();
+ store.openTab({
+ workspaceKey: WORKSPACE_KEY,
+ target: { kind: "terminal", terminalId: "term-1" },
+ intent: "reveal",
+ });
+ store.setLayoutMode("compact");
+ sheetTabId = store.openTab({
+ workspaceKey: WORKSPACE_KEY,
+ target: sheetTarget,
+ intent: "reveal",
+ });
+ setPanelInstanceAttributes(
+ { serverId: "server-1", workspaceId: "workspace-1", tabId: sheetTabId ?? "" },
+ { modified: true },
+ );
+
+ const firstPreviewTabId = previewFile("src/first.ts");
+ const secondPreviewTabId = previewFile("src/second.ts");
+
+ const state = useWorkspaceLayoutStore.getState();
+ expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab.target).toEqual(sheetTarget);
+ expect(secondPreviewTabId).toBe(firstPreviewTabId);
+ const root = state.layoutByWorkspace[WORKSPACE_KEY].root;
+ const tabsById = new Map(collectAllTabs(root).map((tab) => [tab.tabId, tab.target]));
+ expect(findPaneById(root, "main")?.tabIds.map((tabId) => tabsById.get(tabId))).toEqual([
+ { kind: "terminal", terminalId: "term-1" },
+ { kind: "file", path: "src/second.ts" },
+ ]);
+ });
+});
+
+describe("opening beside", () => {
+ const sidePreference = { ...DEFAULT_APP_SETTINGS.openInSidePane, subagents: true };
+
+ function openSubagentWithSidePreference(): string | null {
+ useWorkspaceLayoutStore.getState().openTab({
+ workspaceKey: WORKSPACE_KEY,
+ target: { kind: "file", path: "src/open.ts" },
+ intent: "reveal",
+ });
+ return openPreferredWorkspaceTarget({
+ workspaceKey: WORKSPACE_KEY,
+ target: { kind: "file", path: "src/subagent.ts" },
+ source: "subagents",
+ preferences: sidePreference,
+ });
+ }
+
+ function paneIdHolding(tabId: string | null): string | undefined {
+ const root = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY].root;
+ return collectAllPanes(root).find((pane) => pane.tabIds.includes(tabId ?? ""))?.id;
+ }
+
+ it("follows the side preference where the workspace renders split panes", () => {
+ useWorkspaceLayoutStore.getState().setLayoutMode("split-panes");
+
+ const tabId = openSubagentWithSidePreference();
+
+ const sidePaneId = useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY];
+ expect(sidePaneId).toEqual(expect.any(String));
+ expect(paneIdHolding(tabId)).toBe(sidePaneId);
+ });
+
+ it("keeps a single-pane workspace in one pane despite the side preference", () => {
+ useWorkspaceLayoutStore.getState().setLayoutMode("single-pane");
+
+ const preferredTabId = openSubagentWithSidePreference();
+ const besideTabId = openWorkspaceTargetBeside({
+ workspaceKey: WORKSPACE_KEY,
+ target: { kind: "file", path: "src/beside.ts" },
+ });
+
+ expect(useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY]).toBeFalsy();
+ expect(paneIdHolding(preferredTabId)).toBe("main");
+ expect(paneIdHolding(besideTabId)).toBe("main");
+ });
+});
diff --git a/packages/app/src/workspace-tabs/open-beside.ts b/packages/app/src/workspace-tabs/open-beside.ts
index b36d227c3b..0c0c9ebdf5 100644
--- a/packages/app/src/workspace-tabs/open-beside.ts
+++ b/packages/app/src/workspace-tabs/open-beside.ts
@@ -22,13 +22,11 @@ interface OpenWorkspaceTargetInput {
}
export interface OpenPreferredWorkspaceTargetInput extends OpenWorkspaceTargetInput {
- isCompact: boolean;
source: OpenInSidePaneSource;
preferences: OpenInSidePanePreferences;
}
interface OpenWorkspaceTargetAtLocationInput extends OpenWorkspaceTargetInput {
- isCompact: boolean;
location: WorkspaceTargetOpenLocation;
}
@@ -39,6 +37,11 @@ interface OpenPreferredWorkspacePreviewInput extends OpenPreferredWorkspaceTarge
lastMainPaneId: string | null;
}
+/** A side pane exists only where the workspace renders split panes. */
+function canOpenBeside(): boolean {
+ return useWorkspaceLayoutStore.getState().layoutMode === "split-panes";
+}
+
function resolveMainPane(input: {
workspaceKey: string;
explorerSidebarPaneId: string | null;
@@ -86,7 +89,6 @@ export function openPreferredWorkspaceTarget(
input: OpenPreferredWorkspaceTargetInput,
): string | null {
return openWorkspaceTargetAtLocation({
- isCompact: input.isCompact,
workspaceKey: input.workspaceKey,
target: input.target,
location: input.preferences[input.source] ? "side" : "main",
@@ -105,7 +107,7 @@ export function openWorkspaceTargetAtLocation(
layout &&
collectAllTabs(layout.root).some((tab) => workspaceTabTargetsEqual(tab.target, input.target)),
);
- const shouldOpenBeside = !input.isCompact && input.location === "side";
+ const shouldOpenBeside = canOpenBeside() && input.location === "side";
let placement: WorkspaceTabPlacement | undefined;
if (shouldOpenBeside && !targetAlreadyExists) {
const paneId = store.ensureSidePane(input.workspaceKey);
@@ -135,7 +137,13 @@ export function openPreferredWorkspacePreview(
const existing = collectAllTabs(layout.root).some((tab) =>
workspaceTabTargetsEqual(tab.target, input.target),
);
- if (existing) return openPreferredWorkspaceTarget(input);
+ // The sheet is already a single replaceable slot, so it needs no preview tab.
+ if (
+ existing ||
+ store.opensInSheet({ workspaceKey: input.workspaceKey, target: input.target, intent: "reveal" })
+ ) {
+ return openPreferredWorkspaceTarget(input);
+ }
const mainPane = resolveMainPane({
workspaceKey: input.workspaceKey,
@@ -143,7 +151,7 @@ export function openPreferredWorkspacePreview(
lastMainPaneId: input.lastMainPaneId,
});
const destinationPaneId =
- !input.isCompact && input.preferences[input.source]
+ canOpenBeside() && input.preferences[input.source]
? store.ensureSidePane(input.workspaceKey)
: mainPane?.id;
if (!destinationPaneId) return null;
@@ -173,9 +181,15 @@ export function openPreferredWorkspacePreview(
});
}
-/** Explicit Open to Side: creates the side pane and places the target there. */
+/**
+ * Explicit Open to Side: creates the side pane and places the target there. Without split panes
+ * the target opens like any other.
+ */
export function openWorkspaceTargetBeside(input: OpenWorkspaceTargetInput): string | null {
if (!input.workspaceKey) return null;
+ if (!canOpenBeside()) {
+ return openWorkspaceTargetAtLocation({ ...input, location: "main" });
+ }
const store = useWorkspaceLayoutStore.getState();
const paneId = store.ensureSidePane(input.workspaceKey);
return store.openTab({
diff --git a/packages/app/src/workspace-tabs/open-supporting-view.ts b/packages/app/src/workspace-tabs/open-supporting-view.ts
index b91bf732c9..bb567d4bd0 100644
--- a/packages/app/src/workspace-tabs/open-supporting-view.ts
+++ b/packages/app/src/workspace-tabs/open-supporting-view.ts
@@ -46,7 +46,6 @@ export function openWorkspaceChanges(input: OpenWorkspaceChangesInput): string |
return null;
}
return openPreferredWorkspaceTarget({
- isCompact: input.isCompact,
workspaceKey: input.workspaceKey,
target: { kind: "working_diff" },
source: "diffs",
@@ -70,7 +69,6 @@ export function openWorkspacePullRequest(input: OpenWorkspacePullRequestInput):
return null;
}
return openWorkspaceTargetAtLocation({
- isCompact: input.isCompact,
workspaceKey: input.workspaceKey,
target: { kind: "pull_request" },
location: input.destination,
diff --git a/packages/app/vitest-environment.test.ts b/packages/app/vitest-environment.test.ts
new file mode 100644
index 0000000000..95b9b9343b
--- /dev/null
+++ b/packages/app/vitest-environment.test.ts
@@ -0,0 +1,67 @@
+import { spawnSync } from "node:child_process";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { expect, it } from "vitest";
+
+const appDir = fileURLToPath(new URL(".", import.meta.url));
+const rootDir = path.resolve(appDir, "../..");
+const require = createRequire(import.meta.url);
+const vitestEntry = path.join(path.dirname(require.resolve("vitest/package.json")), "vitest.mjs");
+const tsgoEntry = path.join(
+ path.dirname(require.resolve("@typescript/native-preview/package.json")),
+ "bin/tsgo.js",
+);
+
+it("the test configuration typechecks with Expo's generated environment declaration", () => {
+ const fixtureDir = mkdtempSync(path.join(appDir, ".vitest-expo-env-"));
+ try {
+ writeFileSync(path.join(fixtureDir, "expo-env.d.ts"), '/// \n');
+ writeFileSync(
+ path.join(fixtureDir, "tsconfig.json"),
+ JSON.stringify({
+ extends: "../tsconfig.json",
+ files: ["../vitest.config.ts", "expo-env.d.ts"],
+ include: [],
+ }),
+ );
+ const result = spawnSync(
+ process.execPath,
+ [tsgoEntry, "--noEmit", "--project", path.join(fixtureDir, "tsconfig.json")],
+ { cwd: appDir, encoding: "utf8", timeout: 30_000 },
+ );
+
+ expect(result.error).toBeUndefined();
+ expect(result.status, result.stdout + result.stderr).toBe(0);
+ } finally {
+ rmSync(fixtureDir, { recursive: true, force: true });
+ }
+}, 40_000);
+
+it.each([
+ {
+ name: "root",
+ cwd: rootDir,
+ args: ["packages/app/src/screens/workspace/use-mounted-tab-set.test.ts"],
+ },
+ {
+ name: "app",
+ cwd: appDir,
+ args: ["--project", "unit", "src/screens/workspace/use-mounted-tab-set.test.ts"],
+ },
+])(
+ "$name tests can render React when the shell exports NODE_ENV=production",
+ ({ cwd, args }) => {
+ const result = spawnSync(process.execPath, [vitestEntry, "run", ...args], {
+ cwd,
+ env: { ...process.env, NODE_ENV: "production" },
+ encoding: "utf8",
+ timeout: 30_000,
+ });
+
+ expect(result.error).toBeUndefined();
+ expect(result.status, result.stdout + result.stderr).toBe(0);
+ },
+ 40_000,
+);
diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts
index 521fb6dbe0..e2d67aca70 100644
--- a/packages/app/vitest.config.ts
+++ b/packages/app/vitest.config.ts
@@ -3,6 +3,9 @@ import { playwright } from "@vitest/browser-playwright";
import path from "path";
import fs from "fs";
+// Set this before Vite resolves dependencies and Vitest starts its workers.
+Object.assign(process.env, { NODE_ENV: "test" });
+
const appNodeModules = path.resolve(__dirname, "node_modules");
const rootNodeModules = path.resolve(__dirname, "../../node_modules");
const resolvePackageEntry = (packageName: string) => {
@@ -22,7 +25,12 @@ export default defineConfig({
test: {
name: "unit",
environment: "node",
- include: ["src/**/*.{test,spec}.{ts,tsx}", "native-release-version.test.ts"],
+ include: [
+ "src/**/*.{test,spec}.{ts,tsx}",
+ "native-release-version.test.ts",
+ "app.config.test.ts",
+ "vitest-environment.test.ts",
+ ],
setupFiles: [path.resolve(__dirname, "vitest.setup.ts")],
exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"],
},
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 08525ae169..4fdcfba018 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
- "@getpaseo/client": "0.11.0-beta.5",
- "@getpaseo/protocol": "0.11.0-beta.5",
- "@getpaseo/server": "0.11.0-beta.5",
+ "@getpaseo/client": "0.11.2",
+ "@getpaseo/protocol": "0.11.2",
+ "@getpaseo/server": "0.11.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts
index 676f79234d..18f4348ff7 100644
--- a/packages/cli/src/cli-surface.test.ts
+++ b/packages/cli/src/cli-surface.test.ts
@@ -46,10 +46,25 @@ describe("canonical CLI surface", () => {
expect(help).toContain("--forge ");
});
- it("uses background for execution and reserves detach for ownership", () => {
+ it("uses --no-wait for execution, --internal for visibility, and reserves detach for ownership", () => {
const run = createCli().commands.find((command) => command.name() === "run");
- expect(run?.helpInformation()).toContain("--background");
- expect(run?.helpInformation()).not.toContain("--detach");
+ const help = run?.helpInformation();
+ expect(help).toContain("--no-wait");
+ expect(help).not.toContain("--background");
+ expect(help).not.toContain("--background");
+ expect(help).not.toContain("--detach");
+ });
+
+ it("hides internal agents and workspaces from listings unless --internal is passed", () => {
+ const cli = createCli();
+ const ls = cli.commands.find((command) => command.name() === "ls");
+ const workspace = cli.commands.find((command) => command.name() === "workspace");
+ const workspaceLs = workspace?.commands.find((command) => command.name() === "ls");
+ const workspaceCreate = workspace?.commands.find((command) => command.name() === "create");
+
+ expect(ls?.helpInformation()).toContain("--background");
+ expect(workspaceLs?.helpInformation()).toContain("--background");
+ expect(workspaceCreate?.helpInformation()).toContain("--background");
});
it("offers thinking configuration when running, updating, and scheduling agents", () => {
@@ -96,3 +111,15 @@ describe("canonical CLI surface", () => {
).toContain("--id ");
});
});
+
+it("preserves omitted and explicit false workspace background intent", () => {
+ const readOptions = (args: string[]) => {
+ const workspace = createCli().commands.find((command) => command.name() === "workspace")!;
+ const create = workspace.commands.find((command) => command.name() === "create")!;
+ create.parseOptions(args);
+ return create.opts();
+ };
+ expect(readOptions([]).background).toBeUndefined();
+ expect(readOptions(["--background"]).background).toBe(true);
+ expect(readOptions(["--no-background"]).background).toBe(false);
+});
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index f0c0cfcf7b..dceb3f372d 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -3,6 +3,7 @@ import { Command, Option } from "commander";
import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
import { createPermitCommand } from "./commands/permit/index.js";
+import { createUsageCommand } from "./commands/usage/index.js";
import { createProviderCommand } from "./commands/provider/index.js";
import { createPluginCommand } from "./commands/plugin/index.js";
import { createProjectCommand } from "./commands/project/index.js";
@@ -148,6 +149,7 @@ export function createCli(): Command {
// Provider commands
program.addCommand(createProviderCommand());
+ program.addCommand(createUsageCommand());
program.addCommand(createPluginCommand());
// Speech model commands
diff --git a/packages/cli/src/commands/agent/logs.test.ts b/packages/cli/src/commands/agent/logs.test.ts
new file mode 100644
index 0000000000..e551aa3791
--- /dev/null
+++ b/packages/cli/src/commands/agent/logs.test.ts
@@ -0,0 +1,151 @@
+import { describe, expect, it } from "vitest";
+import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
+import { createFollowTranscriptWriter, formatAgentActivityTranscript } from "./logs.js";
+
+function collect(items: readonly AgentTimelineItem[], turnId?: string): string {
+ let output = "";
+ const writer = createFollowTranscriptWriter((chunk) => {
+ output += chunk;
+ });
+ for (const item of items) {
+ writer.push(item, turnId);
+ }
+ writer.end();
+ return output;
+}
+
+describe("createFollowTranscriptWriter", () => {
+ it("matches one-shot logs when reasoning and assistant text alternate", () => {
+ const items: AgentTimelineItem[] = [
+ { type: "reasoning", text: "Check " },
+ { type: "reasoning", text: "the commands." },
+ { type: "assistant_message", text: "I will list the commands.", messageId: "msg_1" },
+ { type: "reasoning", text: "Then summarize." },
+ { type: "assistant_message", text: "Done.", messageId: "msg_2" },
+ ];
+
+ expect(collect(items).trimEnd()).toBe(formatAgentActivityTranscript(items));
+ });
+
+ it("keeps a word that arrives split across fragments intact", () => {
+ const output = collect([
+ { type: "assistant_message", text: "the paseo-pr", messageId: "msg_1" },
+ { type: "assistant_message", text: "-cycle label\n", messageId: "msg_1" },
+ ]);
+
+ expect(output).toBe("the paseo-pr-cycle label\n");
+ });
+
+ it("streams text as it arrives without inventing a line break", () => {
+ const chunks: string[] = [];
+ const writer = createFollowTranscriptWriter((chunk) => {
+ chunks.push(chunk);
+ });
+
+ writer.push({
+ type: "assistant_message",
+ text: "first line",
+ messageId: "msg_1",
+ });
+ expect(chunks.join("")).toBe("first line");
+
+ writer.push({
+ type: "assistant_message",
+ text: "\nsecond",
+ messageId: "msg_1",
+ });
+ expect(chunks.join("")).toBe("first line\nsecond");
+
+ writer.push({
+ type: "assistant_message",
+ text: " line\n",
+ messageId: "msg_1",
+ });
+ expect(chunks.join("")).toBe("first line\nsecond line");
+
+ writer.end();
+ expect(chunks.join("")).toBe("first line\nsecond line\n");
+ });
+
+ it("emits the same transcript the one-shot command prints for the same items", () => {
+ const items: AgentTimelineItem[] = [
+ {
+ type: "assistant_message",
+ text: "Plan for paseo-pr",
+ messageId: "msg_1",
+ },
+ {
+ type: "assistant_message",
+ text: "-cycle:\n- build\n- test\n",
+ messageId: "msg_1",
+ },
+ { type: "error", message: "boom" },
+ { type: "assistant_message", text: "done", messageId: "msg_2" },
+ ];
+
+ expect(collect(items).trimEnd()).toBe(formatAgentActivityTranscript(items));
+ });
+
+ it("starts a new message when the messageId changes", () => {
+ const output = collect([
+ { type: "assistant_message", text: "first", messageId: "msg_1" },
+ { type: "assistant_message", text: "second", messageId: "msg_2" },
+ ]);
+
+ expect(output).toBe("first\nsecond\n");
+ });
+
+ it("does not merge fragments across turns", () => {
+ let output = "";
+ const writer = createFollowTranscriptWriter((chunk) => {
+ output += chunk;
+ });
+
+ writer.push({ type: "assistant_message", text: "first" }, "turn_1");
+ writer.push({ type: "assistant_message", text: "second" }, "turn_2");
+ writer.end();
+
+ expect(output).toBe("first\nsecond\n");
+ });
+
+ it("prints the thought prefix once for a merged reasoning stream", () => {
+ const output = collect([
+ { type: "reasoning", text: "checking the" },
+ { type: "reasoning", text: " lockfile\nthen the build\n" },
+ ]);
+
+ expect(output).toBe("[Thought] checking the lockfile\nthen the build\n");
+ });
+
+ it("keeps reasoning blocks separate across hidden timeline items", () => {
+ let output = "";
+ const writer = createFollowTranscriptWriter((chunk) => {
+ output += chunk;
+ }, "reasoning");
+ writer.push({ type: "reasoning", text: "checking files" });
+ writer.push({
+ type: "tool_call",
+ callId: "tool_1",
+ name: "Bash",
+ status: "completed",
+ error: null,
+ detail: { type: "unknown", input: "ls", output: "files" },
+ });
+ writer.push({ type: "reasoning", text: "running tests" });
+ writer.end();
+
+ expect(output).toBe("[Thought] checking files\n[Thought] running tests\n");
+ });
+
+ it("keeps interior blank lines and drops trailing whitespace", () => {
+ const output = collect([
+ {
+ type: "assistant_message",
+ text: " # Title\n\nBody\n\n ",
+ messageId: "msg_1",
+ },
+ ]);
+
+ expect(output).toBe("# Title\n\nBody\n");
+ });
+});
diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts
index f555e27b80..ed77d4be62 100644
--- a/packages/cli/src/commands/agent/logs.ts
+++ b/packages/cli/src/commands/agent/logs.ts
@@ -1,7 +1,7 @@
import { Command } from "commander";
import { connectToDaemon } from "../../utils/client.js";
import { waitForStop } from "../../utils/wait-for-stop.js";
-import type { CommandOptions } from "../../output/index.js";
+import type { CommandError, CommandOptions } from "../../output/index.js";
import {
fetchProjectedTimelineItems,
LIVE_HISTORY_FETCH_TIMEOUT_MS,
@@ -32,12 +32,29 @@ export type AgentLogsResult = void;
export const NO_ACTIVITY_MESSAGE = "No activity to display.";
+interface FetchAgentTimelineItemsOptions {
+ timeoutMs?: number;
+ sinceTimestampMs?: number;
+}
+
+interface FollowModeInput {
+ client: DaemonClient;
+ agentId: string;
+ options: AgentLogsOptions;
+ sinceTimestampMs: number | undefined;
+}
+
export async function fetchAgentTimelineItems(
client: DaemonClient,
agentId: string,
- options?: { timeoutMs?: number },
+ options?: FetchAgentTimelineItemsOptions,
): Promise {
- return fetchProjectedTimelineItems({ client, agentId, timeoutMs: options?.timeoutMs });
+ return fetchProjectedTimelineItems({
+ client,
+ agentId,
+ timeoutMs: options?.timeoutMs,
+ sinceTimestampMs: options?.sinceTimestampMs,
+ });
}
export function formatAgentActivityTranscript(
@@ -53,6 +70,134 @@ export function formatAgentActivityTranscript(
);
}
+type StreamingTextType = "assistant_message" | "reasoning";
+
+const THOUGHT_PREFIX = "[Thought] ";
+
+interface PendingStreamingText {
+ type: StreamingTextType;
+ turnId: string | undefined;
+ messageId: string | undefined;
+ buffer: string;
+ emitted: boolean;
+}
+
+export interface FollowTranscriptWriter {
+ push(item: AgentTimelineItem, turnId?: string): void;
+ end(): void;
+}
+
+function lastNonWhitespaceIndex(text: string): number {
+ for (let index = text.length - 1; index >= 0; index -= 1) {
+ if (!/\s/.test(text[index])) {
+ return index;
+ }
+ }
+ return -1;
+}
+
+/**
+ * Follow mode receives one streaming fragment per event, while the one-shot
+ * transcript sees the whole timeline and merges consecutive assistant and
+ * reasoning fragments with exact concatenation (`projectTimelineRows`). Printing
+ * each fragment on its own line splits words at fragment boundaries and
+ * reprints the reasoning prefix, so a chain of fragments is written as one
+ * stream instead: text goes out as it arrives, and only the trailing whitespace
+ * is held back so the message ends trimmed the way the transcript does.
+ */
+export function createFollowTranscriptWriter(
+ write: (chunk: string) => void = (chunk) => void process.stdout.write(chunk),
+ filter?: string,
+): FollowTranscriptWriter {
+ let pending: PendingStreamingText | null = null;
+
+ const emit = (chain: PendingStreamingText, text: string): void => {
+ if (!chain.emitted) {
+ chain.emitted = true;
+ if (chain.type === "reasoning") {
+ write(THOUGHT_PREFIX);
+ }
+ }
+ write(text);
+ };
+
+ const flushContent = (chain: PendingStreamingText): void => {
+ if (!chain.emitted) {
+ chain.buffer = chain.buffer.replace(/^\s+/, "");
+ }
+ const lastContent = lastNonWhitespaceIndex(chain.buffer);
+ if (lastContent < 0) {
+ return;
+ }
+ emit(chain, chain.buffer.slice(0, lastContent + 1));
+ chain.buffer = chain.buffer.slice(lastContent + 1);
+ };
+
+ const closePending = (): void => {
+ const chain = pending;
+ pending = null;
+ if (!chain) {
+ return;
+ }
+ const tail = chain.emitted ? chain.buffer.trimEnd() : chain.buffer.trim();
+ if (tail) {
+ emit(chain, tail);
+ }
+ if (chain.emitted) {
+ write("\n");
+ }
+ };
+
+ const continuesChain = (item: AgentTimelineItem, turnId: string | undefined): boolean => {
+ if (!pending || pending.type !== item.type || pending.turnId !== turnId) {
+ return false;
+ }
+ if (item.type !== "assistant_message") {
+ return true;
+ }
+ return item.messageId === undefined || item.messageId === pending.messageId;
+ };
+
+ const startChain = (
+ item: Extract,
+ turnId: string | undefined,
+ ): PendingStreamingText => {
+ closePending();
+ const chain: PendingStreamingText = {
+ type: item.type,
+ turnId,
+ messageId: item.type === "assistant_message" ? item.messageId : undefined,
+ buffer: "",
+ emitted: false,
+ };
+ pending = chain;
+ return chain;
+ };
+
+ return {
+ push(item, turnId) {
+ if (!matchesFilter(item, filter)) {
+ closePending();
+ return;
+ }
+ if (item.type === "assistant_message" || item.type === "reasoning") {
+ const chain = continuesChain(item, turnId) && pending ? pending : startChain(item, turnId);
+ chain.buffer += item.text;
+ flushContent(chain);
+ return;
+ }
+ closePending();
+ const transcript = formatAgentActivityTranscript([item]);
+ if (transcript && transcript !== NO_ACTIVITY_MESSAGE) {
+ write(`${transcript}\n`);
+ }
+ },
+ end() {
+ closePending();
+ },
+ };
+}
+
function parseTailCount(raw: string | undefined): number | undefined {
if (raw === undefined) return undefined;
const parsed = Number.parseInt(raw, 10);
@@ -62,6 +207,19 @@ function parseTailCount(raw: string | undefined): number | undefined {
return parsed;
}
+function parseSinceTimestamp(raw: string | undefined): number | undefined {
+ if (raw === undefined) return undefined;
+ const timestampMs = Date.parse(raw);
+ if (Number.isNaN(timestampMs)) {
+ throw {
+ code: "INVALID_TIMESTAMP",
+ message: `Invalid --since value: ${raw}`,
+ details: "Use a timestamp such as 2026-01-01T00:00:00Z.",
+ } satisfies CommandError;
+ }
+ return timestampMs;
+}
+
/**
* Check if a timeline item matches the filter type
*/
@@ -98,6 +256,7 @@ export async function runLogsCommand(
process.exit(1);
}
+ const sinceTimestampMs = parseSinceTimestamp(options.since);
const client = await connectToDaemon({ target: options.daemonTarget });
try {
@@ -118,12 +277,19 @@ export async function runLogsCommand(
await client.close().catch(() => {});
process.exit(1);
}
- await runFollowMode(client, resolvedId, options);
+ await runFollowMode({
+ client,
+ agentId: resolvedId,
+ options,
+ sinceTimestampMs,
+ });
return;
}
// Fetch timeline directly via cursor RPC.
- let timelineItems = await fetchAgentTimelineItems(client, resolvedId);
+ let timelineItems = await fetchAgentTimelineItems(client, resolvedId, {
+ sinceTimestampMs,
+ });
// Apply filter
if (options.filter) {
@@ -159,11 +325,12 @@ export async function runLogsCommand(
/**
* Follow mode: stream logs in real-time until interrupted
*/
-async function runFollowMode(
- client: DaemonClient,
- agentId: string,
- options: AgentLogsOptions,
-): Promise {
+async function runFollowMode({
+ client,
+ agentId,
+ options,
+ sinceTimestampMs,
+}: FollowModeInput): Promise {
const DEFAULT_FOLLOW_TAIL = 10;
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
@@ -172,6 +339,7 @@ async function runFollowMode(
try {
existingItems = await fetchAgentTimelineItems(client, agentId, {
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
+ sinceTimestampMs,
});
} catch (error) {
console.warn("Warning: failed to fetch existing timeline", error);
@@ -194,31 +362,39 @@ async function runFollowMode(
const tailLabel =
tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`;
+ const writer = createFollowTranscriptWriter(undefined, options.filter);
+
const unsubscribe = client.subscribeAgentTimeline(agentId, (message) => {
if (message.type === "agent.timeline.replacement") {
+ writer.end();
console.log("\n[Timeline replaced; earlier output is no longer current]");
return;
}
if (message.type === "agent.timeline.error") {
+ writer.end();
console.error(`Timeline observation stopped: ${message.payload.error}`);
return;
}
if (message.type === "agent.timeline.subscription_restored") {
+ writer.end();
console.log("\n[Reconnected; live output resumed. Events may have been missed.]");
return;
}
if (message.payload.event.type === "timeline") {
- const item = message.payload.event.item;
- // Apply filter
- if (options.filter && !matchesFilter(item, options.filter)) {
+ const matchesSince =
+ sinceTimestampMs === undefined || Date.parse(message.payload.timestamp) >= sinceTimestampMs;
+ if (!matchesSince) {
return;
}
- // Print each timeline item as it arrives using the curator format
- const transcript = formatAgentActivityTranscript([item]);
- if (transcript !== NO_ACTIVITY_MESSAGE) {
- console.log(transcript);
- }
+ const item = message.payload.event.item;
+ writer.push(item, message.payload.event.turnId);
+ } else if (
+ message.payload.event.type === "turn_completed" ||
+ message.payload.event.type === "turn_failed" ||
+ message.payload.event.type === "turn_canceled"
+ ) {
+ writer.end();
}
});
@@ -226,6 +402,7 @@ async function runFollowMode(
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
await waitForStop();
+ writer.end();
unsubscribe();
await client.close();
}
diff --git a/packages/cli/src/commands/agent/ls.test.ts b/packages/cli/src/commands/agent/ls.test.ts
index c66b34b94d..8aedf73fad 100644
--- a/packages/cli/src/commands/agent/ls.test.ts
+++ b/packages/cli/src/commands/agent/ls.test.ts
@@ -23,15 +23,21 @@ describe("buildAgentLsFetchOptions", () => {
});
});
+ it("asks the daemon for internal agents only with --internal", () => {
+ expect(buildAgentLsFetchOptions({ background: true, global: true })).toEqual({
+ filter: { includeBackground: true },
+ });
+ });
+
it("fetches global non-archived agents for -g", () => {
expect(buildAgentLsFetchOptions({ global: true })).toEqual({});
});
- it("keeps -a within the active scope", () => {
+ it("includes archived and background agents with -a", () => {
expect(buildAgentLsFetchOptions({ all: true })).toEqual({
- scope: "active",
filter: {
includeArchived: true,
+ includeBackground: true,
},
});
});
@@ -40,6 +46,7 @@ describe("buildAgentLsFetchOptions", () => {
expect(buildAgentLsFetchOptions({ all: true, global: true })).toEqual({
filter: {
includeArchived: true,
+ includeBackground: true,
},
});
});
diff --git a/packages/cli/src/commands/agent/ls.ts b/packages/cli/src/commands/agent/ls.ts
index bb2189c7c4..0990446bc5 100644
--- a/packages/cli/src/commands/agent/ls.ts
+++ b/packages/cli/src/commands/agent/ls.ts
@@ -12,8 +12,9 @@ type FetchAgentsOptions = NonNullable<
export function addLsOptions(cmd: Command): Command {
return cmd
.description("List agents. By default excludes archived agents.")
- .option("-a, --all", "Include archived agents")
+ .option("-a, --all", "Include archived and background agents")
.option("-g, --global", "List agents across all directories")
+ .option("--background", "Include background workspace agents")
.option(
"--label ",
"Filter by label (can be used multiple times)",
@@ -109,6 +110,8 @@ export interface AgentLsOptions extends CommandOptions {
all?: boolean;
/** -g: List agents across all directories */
global?: boolean;
+ /** --background: Include background workspace agents */
+ background?: boolean;
/** Filter by specific status */
status?: string;
/** Filter by specific cwd */
@@ -133,7 +136,7 @@ function parseLabelFilters(labels: string[] | undefined): Record
}
export function buildAgentLsFetchOptions(
- options: Pick,
+ options: Pick,
): FetchAgentsOptions {
const labelFilters = parseLabelFilters(options.label);
const normalizedThinkingOptionId = options.thinking?.trim();
@@ -142,6 +145,9 @@ export function buildAgentLsFetchOptions(
if (options.all) {
daemonFilter.includeArchived = true;
}
+ if (options.background || options.all) {
+ daemonFilter.includeBackground = true;
+ }
if (Object.keys(labelFilters).length > 0) {
daemonFilter.labels = labelFilters;
}
@@ -150,7 +156,7 @@ export function buildAgentLsFetchOptions(
}
const fetchOptions: FetchAgentsOptions = {};
- if (!options.global) {
+ if (!options.global && !options.all) {
fetchOptions.scope = "active";
}
if (Object.keys(daemonFilter).length > 0) {
@@ -163,7 +169,7 @@ export function buildAgentLsFetchOptions(
* Agent ls command semantics:
* - `paseo agent ls` → active non-archived agents
* - `paseo agent ls -g` → global non-archived agents
- * - `paseo agent ls -a` → active agents, including archived
+ * - `paseo agent ls -a` → all agents, including archived and background
* - `paseo agent ls -ag` → global agents, including archived
*/
export async function runLsCommand(
diff --git a/packages/cli/src/commands/agent/run.test.ts b/packages/cli/src/commands/agent/run.test.ts
index b4e3b20bde..5e64ccba81 100644
--- a/packages/cli/src/commands/agent/run.test.ts
+++ b/packages/cli/src/commands/agent/run.test.ts
@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
+import { resolveCallerAgentId as resolveRunCallerAgentId } from "../../utils/caller-agent.js";
import {
resolveExistingRunWorkspace,
- resolveRunCallerAgentId,
runRunCommand,
+ waitsForFinish,
type AgentRunOptions,
} from "./run";
@@ -21,6 +22,15 @@ function daemonWithAgents(...agentIds: string[]) {
};
}
+describe("run wait policy", () => {
+ it("waits by default and stops waiting on --no-wait or a legacy alias", () => {
+ expect(waitsForFinish({})).toBe(true);
+ expect(waitsForFinish({ wait: false })).toBe(false);
+ expect(waitsForFinish({ background: true })).toBe(false);
+ expect(waitsForFinish({ detach: true })).toBe(false);
+ });
+});
+
describe("managed agent caller context", () => {
it("uses a trimmed PASEO_AGENT_ID when the target daemon runs that agent", async () => {
await expect(
@@ -38,17 +48,20 @@ describe("managed agent caller context", () => {
).resolves.toBeUndefined();
});
- it("fails instead of dropping the caller when the lookup loses its connection", async () => {
- const disconnectedDaemon = {
- async fetchAgent(): Promise {
- throw new DaemonConnectionError("Connection lost before message could be sent");
- },
- };
+ it.each(["DAEMON_CONNECTION_LOST", "DAEMON_REQUEST_TIMEOUT"] as const)(
+ "preserves %s instead of dropping the caller",
+ async (code) => {
+ const disconnectedDaemon = {
+ async fetchAgent(): Promise {
+ throw new DaemonConnectionError("Caller lookup transport failed", code);
+ },
+ };
- await expect(
- resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }),
- ).rejects.toBeInstanceOf(DaemonConnectionError);
- });
+ await expect(
+ resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }),
+ ).rejects.toBeInstanceOf(DaemonConnectionError);
+ },
+ );
it("omits blank caller ids", async () => {
await expect(
diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts
index 82817684c2..86cca5f75c 100644
--- a/packages/cli/src/commands/agent/run.ts
+++ b/packages/cli/src/commands/agent/run.ts
@@ -4,7 +4,7 @@ import {
StructuredAgentResponseError,
} from "@getpaseo/server/agent-response";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
-import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
+import { resolveCallerAgentId } from "../../utils/caller-agent.js";
import { connectToDaemon } from "../../utils/client.js";
import type {
CommandOptions,
@@ -27,10 +27,13 @@ export function addRunOptions(cmd: Command): Command {
cmd
.description("Create and start an agent with a task")
.argument("", "The task/prompt for the agent")
- .option("-d, --background", "Run in background")
+ .option("--no-wait", "Return immediately without waiting for completion")
+ // COMPAT(backgroundRunFlag): --background was the documented spelling of
+ // --no-wait. Added in v0.2.0; remove after 2027-04-01.
+ .addOption(new Option("-d, --background", "Legacy alias for --no-wait").hideHelp())
// COMPAT(detachRunFlag): --detach used to mean background execution, not
// ownership transfer. Added in v0.2.0; remove after 2027-01-17.
- .addOption(new Option("--detach", "Legacy alias for --background").hideHelp())
+ .addOption(new Option("--detach", "Legacy alias for --no-wait").hideHelp())
.option("--title ", "Assign a title to the agent")
.addOption(new Option("--name ", "Hidden alias for --title").hideHelp())
.option(
@@ -111,6 +114,7 @@ export const agentRunSchema: OutputSchema = {
};
export interface AgentRunOptions extends CommandOptions {
+ wait?: boolean;
background?: boolean;
detach?: boolean;
title?: string;
@@ -389,19 +393,25 @@ function validateRunOptions(prompt: string, options: AgentRunOptions, outputSche
} satisfies CommandError;
}
+ if (options.background) {
+ console.error("Warning: --background (-d) is deprecated. Use --no-wait instead.");
+ }
+
validateRunWorkspaceOptions(options);
- if (outputSchema && runsInBackground(options)) {
+ if (outputSchema && !waitsForFinish(options)) {
throw {
code: "INVALID_OPTIONS",
- message: "--output-schema cannot be used with --background",
+ message: "--output-schema cannot be used with --no-wait",
details: "Structured output requires waiting for the agent to finish",
} satisfies CommandError;
}
}
-function runsInBackground(options: Pick): boolean {
- return Boolean(options.background || options.detach);
+export function waitsForFinish(
+ options: Pick