Compare commits

...
Author SHA1 Message Date
Mohamed Boudra bf4afc3634 fix(settings): show usage limits approaching exhaustion
Provider status tones could hide utilization risk. Display the strongest signal so high usage reaches warning and danger while provider-specific alerts remain visible.
2026-07-22 13:16:53 +00:00
Mohamed Boudra 42ee5a5949 Restore archived agents from History (#2316)
* fix(app): restore archived agents from History

History navigation lost the explicit archived-agent intent during tab reconciliation, and workspace recovery stopped after restoring the workspace. Preserve the selected tab and recover its provider session as one action while serializing provider resume with timeline hydration.

* fix(app): preserve archived agent recovery invariants

* fix(server): preserve timeline hydration call shape

* fix(app): retain agent tabs through lookup

* fix(server): upgrade in-flight timeline broadcast
2026-07-22 15:03:09 +02:00
Mohamed Boudra 68993b7ab3 fix(desktop): preserve Windows terminal hook smoke command
The outer batch shell expanded the hook variable and control operators before terminal send-keys received them. Encode the probe for PowerShell so expansion happens inside the packaged terminal.
2026-07-22 13:16:27 +02:00
Mohamed Boudra 246c07fba5 fix(cli): make new workspace creation explicit (#2315)
Agent callers now stay in their current workspace unless --new-workspace explicitly requests a separate local or worktree workspace.
2026-07-22 18:26:40 +08:00
Mohamed Boudra 0afcc96370 docs(website): correct provider usage guidance 2026-07-22 12:25:01 +02:00
Mohamed Boudra 3a8d08ba91 Refactor changelog entries and improve descriptions 2026-07-22 12:25:01 +02:00
paseo-ai[bot] f22a8ea8e0 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-22 09:05:28 +00:00
38 changed files with 855 additions and 163 deletions
+5 -5
View File
@@ -5,8 +5,6 @@
### Added
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309))
- Automate local and worktree-backed workspaces through the CLI and agent tools ([#2186](https://github.com/getpaseo/paseo/pull/2186))
- Start a new workspace from a pasted pull request or merge request link ([#2290](https://github.com/getpaseo/paseo/pull/2290))
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
@@ -15,9 +13,11 @@
### Improved
- Workspace and chat sync uses less data without losing or reordering history ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
- Added folders remain independent projects across nested repositories, worktrees, and filesystem mounts ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
- Idle agents release provider resources automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
- Improved parity of CLI and MCP tools for workspace, agent and schedule management ([#2186](https://github.com/getpaseo/paseo/pull/2186))
- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290))
- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
- Oh My Pi now supports Max thinking in imported and new sessions ([#2191](https://github.com/getpaseo/paseo/pull/2191) by [@mvanhorn](https://github.com/mvanhorn))
- Commit history now includes recent pushed and base-branch commits ([#2312](https://github.com/getpaseo/paseo/pull/2312))
+7
View File
@@ -80,6 +80,13 @@ archived workspace. History navigation must not infer workspace lifecycle from `
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
History navigation preserves the selected agent as an explicit recovery target. If both that agent
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
the selected agent as one user action. Other archived agents in the restored workspace remain
recoverable from History. Opening one pins its tab and renders the archived-agent callout before any
provider timeline is loaded; **Unarchive** runs the provider's native unarchive hook (including Codex
`thread/unarchive`) before the normal agent resume and timeline hydration flow.
## Tabs vs archive
These are two distinct concepts that used to be conflated:
+1 -1
View File
@@ -383,7 +383,7 @@ install.
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --isolation local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
```bash
npm run cli -- ls -a -g # List all agents globally
+1 -1
View File
@@ -1 +1 @@
sha256-prXQpWLMwN1JO2tGhGDie/Ud9vnmUSF44581Mm7R7eA=
sha256-Ys/R5+0O3SDYeCSd7hatTuCwV9S0kIO8vhmPKGZsvn0=
@@ -0,0 +1,115 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "./fixtures";
import { openSessions } from "./helpers/archive-tab";
import {
assertChatTranscript,
cleanupRewindFlow,
launchAgent,
sendMessage,
type AgentHandle,
} from "./helpers/rewind-flow";
import type { SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
interface TimelineClient extends SeedDaemonClient {
fetchAgentTimeline(
agentId: string,
options: { direction: "tail"; projection: "projected"; limit: number },
): Promise<unknown>;
}
const INITIAL_PROMPT = "Reply with exactly CODEX_ARCHIVE_TIMELINE_SENTINEL and nothing else.";
const INITIAL_REPLY = "CODEX_ARCHIVE_TIMELINE_SENTINEL";
const FOLLOW_UP_PROMPT = "Reply with exactly CODEX_UNARCHIVED_SENTINEL and nothing else.";
const FOLLOW_UP_REPLY = "CODEX_UNARCHIVED_SENTINEL";
async function historyContainsAgent(client: SeedDaemonClient, agentId: string): Promise<boolean> {
const history = await client.fetchAgentHistory({ page: { limit: 200 } });
return history.entries.some((entry) => entry.agent.id === agentId);
}
test.describe("archived Codex agent recovery", () => {
test.setTimeout(600_000);
test("cold-opens without provider history, then unarchives and restores the conversation", async ({
page,
}) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-archived-codex-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "codex",
cwd,
mode: "full-access",
});
await sendMessage(handle, INITIAL_PROMPT);
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
]);
await handle.client.archiveAgent(handle.agentId);
await expect
.poll(
async () =>
(await handle?.client.fetchAgent({ agentId: handle.agentId }))?.agent.archivedAt ??
null,
{ timeout: 30_000 },
)
.not.toBeNull();
const timelineClient = handle.client as TimelineClient;
await expect(
timelineClient.fetchAgentTimeline(handle.agentId, {
direction: "tail",
projection: "projected",
limit: 100,
}),
).rejects.toThrow(/archiv/i);
await expect
.poll(async () => (handle ? historyContainsAgent(handle.client, handle.agentId) : false), {
timeout: 30_000,
})
.toBe(true);
await page.reload();
await waitForSidebarHydration(page);
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${handle.agentId}`).click();
await expect(
page.getByTestId(`workspace-tab-agent_${handle.agentId}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("agent-load-error")).toHaveCount(0);
await expect(page.getByTestId("agent-timeline-sync-error")).toHaveCount(0);
await expect(page.getByTestId("user-message")).toHaveCount(0);
await page.getByRole("button", { name: "Unarchive" }).click();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 60_000,
});
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
]);
await sendMessage(handle, FOLLOW_UP_PROMPT);
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
{ role: "user", text: FOLLOW_UP_PROMPT },
{ role: "assistant", text: FOLLOW_UP_REPLY },
]);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
+1 -1
View File
@@ -144,7 +144,7 @@ export interface SeedDaemonClient {
} | null;
fetchAgentHistory(options?: {
page?: { limit: number };
}): Promise<{ entries: Array<{ id: string }> }>;
}): Promise<{ entries: Array<{ agent: { id: string } }> }>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
+78 -9
View File
@@ -44,7 +44,10 @@ test.describe("Worktree restore", () => {
tempRepo = await createTempGitRepo("wt-restore-");
});
async function createArchivedMissingWorktree(prefix: string) {
async function createArchivedMissingWorktree(
prefix: string,
options: { agentCount?: number; keepAgentsArchived?: boolean } = {},
) {
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
@@ -53,11 +56,19 @@ test.describe("Worktree restore", () => {
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `${prefix}-${randomUUID().slice(0, 8)}`,
});
const agents = await Promise.all(
Array.from({ length: options.agentCount ?? 1 }, () =>
createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `${prefix}-${randomUUID().slice(0, 8)}`,
}),
),
);
const agent = agents[0];
if (!agent) {
throw new Error("Expected at least one archived-worktree agent");
}
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
await expect
@@ -67,11 +78,21 @@ test.describe("Worktree restore", () => {
// Match the remote cloud-race record: workspace archived and absent, while
// the surviving closed agent record is not agent-archived. Refresh now owns
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
await client.refreshAgent(agent.id).catch(() => undefined);
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
if (options.keepAgentsArchived) {
for (const archivedAgent of agents) {
await expect
.poll(() => fetchAgentArchivedAt(client, archivedAgent.id), { timeout: 30_000 })
.not.toBeNull();
}
} else {
await client.refreshAgent(agent.id).catch(() => undefined);
await expect
.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 })
.toBeNull();
}
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
return { agent, worktree };
return { agent, agents, worktree };
}
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
@@ -241,6 +262,54 @@ test.describe("Worktree restore", () => {
).toHaveText(switchedBranch, { timeout: 30_000 });
});
test("recovers the selected agent with its workspace and later rescues another archived agent", async ({
page,
}) => {
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
agentCount: 2,
keepAgentsArchived: true,
});
const [firstAgent, secondAgent] = agents;
if (!firstAgent || !secondAgent) {
throw new Error("Expected two archived agents");
}
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${firstAgent.id}`).click();
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await page.getByTestId("workspace-recovery-action").click();
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await expect(
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
.toBeNull();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 30_000,
});
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${secondAgent.id}`).click();
await expect(
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Unarchive" }).click();
await expect
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
.toBeNull();
});
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
@@ -110,7 +110,9 @@ function HostWorkspaceRouteContent() {
const openValue = getParamValue(globalParams.open);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent";
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null;
const isAgentOpenIntent = recoveryAgentId !== null;
const isOpenIntentWaitingForWorkspace = Boolean(
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
);
@@ -147,7 +149,6 @@ function HostWorkspaceRouteContent() {
}
consumedIntentRef.current = consumptionKey;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (openIntent) {
prepareWorkspaceTab({
serverId,
@@ -171,6 +172,7 @@ function HostWorkspaceRouteContent() {
hasHydratedWorkspaceLayoutStore,
isOpenIntentWaitingForWorkspace,
navigation,
openIntent,
openValue,
rootNavigationState?.key,
serverId,
@@ -185,10 +187,16 @@ function HostWorkspaceRouteContent() {
return null;
}
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} recoveryAgentId={recoveryAgentId} />;
}
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
function WorkspaceDeck({
recoveryRequested,
recoveryAgentId,
}: {
recoveryRequested: boolean;
recoveryAgentId: string | null;
}) {
const activeSelection = useActiveWorkspaceSelection();
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
activeSelection ? [activeSelection] : [],
@@ -234,6 +242,7 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
selection={selection}
activeSelection={activeSelection}
recoveryRequested={recoveryRequested}
recoveryAgentId={recoveryAgentId}
onUnmountInactive={unmountWorkspaceSelection}
/>
);
@@ -246,11 +255,13 @@ function WorkspaceDeckEntry({
selection,
activeSelection,
recoveryRequested,
recoveryAgentId,
onUnmountInactive,
}: {
selection: ActiveWorkspaceSelection;
activeSelection: ActiveWorkspaceSelection;
recoveryRequested: boolean;
recoveryAgentId: string | null;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) {
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
@@ -282,6 +293,7 @@ function WorkspaceDeckEntry({
workspaceId={selection.workspaceId}
isRouteFocused={isActive}
recoveryRequested={isActive && recoveryRequested}
recoveryAgentId={isActive ? recoveryAgentId : null}
/>
</RetainedPanel>
);
@@ -394,6 +394,7 @@ export function AgentList({
serverId,
agentId,
workspaceId: agent.workspaceId,
pin: true,
});
},
[isActionSheetVisible, onAgentSelect],
@@ -9,6 +9,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { Button } from "@/components/ui/button";
import type { Theme } from "@/styles/theme";
import { toErrorMessage } from "@/utils/error-messages";
interface ArchivedAgentCalloutProps {
serverId: string;
@@ -21,6 +22,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const [isUnarchiving, setIsUnarchiving] = useState(false);
const [unarchiveError, setUnarchiveError] = useState<string | null>(null);
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
@@ -32,10 +34,11 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
const handleUnarchive = useCallback(async () => {
if (!client || !isConnected || isUnarchiving) return;
setIsUnarchiving(true);
setUnarchiveError(null);
try {
await client.refreshAgent(agentId);
} catch (error) {
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
setUnarchiveError(toErrorMessage(error));
setIsUnarchiving(false);
}
}, [client, isConnected, isUnarchiving, agentId]);
@@ -44,16 +47,23 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
<Animated.View style={containerStyle}>
<View style={styles.inputAreaContainer}>
<View style={styles.inputAreaContent}>
<View style={styles.callout}>
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
<Button
size="sm"
variant="secondary"
onPress={handleUnarchive}
disabled={!isConnected || isUnarchiving}
>
{t("agentPanel.archived.unarchive")}
</Button>
<View style={styles.calloutStack}>
<View style={styles.callout}>
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
<Button
size="sm"
variant="secondary"
onPress={handleUnarchive}
disabled={!isConnected || isUnarchiving}
>
{t("agentPanel.archived.unarchive")}
</Button>
</View>
{unarchiveError ? (
<Text style={styles.errorText} testID="agent-unarchive-error">
{unarchiveError}
</Text>
) : null}
</View>
</View>
</View>
@@ -97,8 +107,16 @@ const styles = StyleSheet.create((theme: Theme) => ({
md: theme.spacing[6],
},
},
calloutStack: {
gap: theme.spacing[2],
},
calloutText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
errorText: {
color: theme.colors.statusDanger,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
})) as unknown as Record<string, object>;
@@ -57,6 +57,7 @@ function createAgentWithStatus({ id, status }: { id: string; status: Agent["stat
function createBaseInput(): AgentScreenMachineInput {
return {
agent: null,
isArchived: false,
continuity: { kind: "none" },
missingAgentState: { kind: "idle" },
isConnected: true,
@@ -385,6 +386,22 @@ describe("deriveAgentScreenViewState", () => {
expect(result.memory.lastReadyAgent).toBeNull();
});
it("renders an archived agent before provider history is initialized", () => {
const result = deriveAgentScreenViewState({
input: {
...createBaseInput(),
agent: createAgent("agent-1"),
isArchived: true,
needsAuthoritativeSync: true,
},
memory: createBaseMemory(),
});
const ready = expectReadyState(result.state);
expect(ready.agent.id).toBe("agent-1");
expect(ready.sync).toEqual({ status: "idle" });
});
it("keeps optimistic create non-blocking while timeline and authoritative history catch up", () => {
const memory = createBaseMemory();
const input: AgentScreenMachineInput = {
@@ -41,6 +41,7 @@ export type AgentScreenMissingState =
export interface AgentScreenMachineInput {
agent: AgentScreenAgent | null;
isArchived: boolean;
missingAgentState: AgentScreenMissingState;
isConnected: boolean;
isArchivingCurrentAgent: boolean;
@@ -61,6 +62,7 @@ function hasOptimisticCreateContinuity(input: AgentScreenMachineInput): boolean
function shouldBlockInitialAuthoritativeReadyState(input: AgentScreenMachineInput): boolean {
return (
!input.isArchived &&
!hasOptimisticCreateContinuity(input) &&
!input.hasHydratedHistoryBefore &&
(input.needsAuthoritativeSync || input.isHistorySyncing)
@@ -164,6 +166,9 @@ function resolveAgentScreenSync(args: {
hadInitialSyncFailure: boolean;
}): AgentScreenReadySyncState {
const { input, hadInitialSyncFailure } = args;
if (input.isArchived) {
return { status: "idle" };
}
if (!input.isConnected) {
return { status: "reconnecting" };
}
+37 -2
View File
@@ -341,13 +341,14 @@ function useAgentPanelDescriptor(
}
function AgentPanel() {
const { serverId, target, openFileInWorkspace } = usePaneContext();
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
const { isInteractive } = usePaneFocus();
invariant(target.kind === "agent", "AgentPanel requires agent target");
return (
<AgentPanelContent
serverId={serverId}
workspaceId={workspaceId}
agentId={target.agentId}
isPaneFocused={isInteractive}
onOpenWorkspaceFile={openFileInWorkspace}
@@ -481,11 +482,13 @@ type AgentLookupState =
function AgentPanelContent({
serverId,
workspaceId,
agentId,
isPaneFocused,
onOpenWorkspaceFile,
}: {
serverId: string;
workspaceId: string;
agentId: string;
isPaneFocused: boolean;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
@@ -535,6 +538,7 @@ function AgentPanelContent({
return (
<AgentPanelBody
serverId={resolvedServerId}
workspaceId={workspaceId}
agentId={resolvedAgentId}
isPaneFocused={isPaneFocused}
client={runtimeClient}
@@ -547,6 +551,7 @@ function AgentPanelContent({
function AgentPanelBody({
serverId,
workspaceId,
agentId,
isPaneFocused,
client,
@@ -555,6 +560,7 @@ function AgentPanelBody({
onOpenWorkspaceFile,
}: {
serverId: string;
workspaceId: string;
agentId?: string;
isPaneFocused: boolean;
client: ReturnType<typeof useHostRuntimeClient>;
@@ -585,6 +591,8 @@ function AgentPanelBody({
);
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
const lookupAttemptTokenRef = useRef(0);
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const resolvePendingAgent = useWorkspaceLayoutStore((state) => state.resolvePendingAgent);
useEffect(() => {
lookupAttemptTokenRef.current += 1;
@@ -596,6 +604,9 @@ function AgentPanelBody({
return;
}
if (agentState.id) {
if (workspaceKey) {
resolvePendingAgent(workspaceKey, agentId);
}
if (lookupState.tag !== "idle") {
setLookupState({ tag: "idle" });
}
@@ -618,6 +629,9 @@ function AgentPanelBody({
return;
}
if (!result) {
if (workspaceKey) {
resolvePendingAgent(workspaceKey, agentId);
}
setLookupState({
tag: "not_found",
message: `Agent not found: ${agentId}`,
@@ -626,6 +640,9 @@ function AgentPanelBody({
}
storeFetchedAgentDetail({ serverId, result });
if (workspaceKey) {
resolvePendingAgent(workspaceKey, agentId);
}
setLookupState({ tag: "idle" });
return;
})
@@ -635,12 +652,25 @@ function AgentPanelBody({
}
const message = toErrorMessage(error);
if (isNotFoundErrorMessage(message)) {
if (workspaceKey) {
resolvePendingAgent(workspaceKey, agentId);
}
setLookupState({ tag: "not_found", message });
return;
}
setLookupState({ tag: "error", message });
});
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
}, [
agentId,
agentState.id,
client,
hasSession,
isConnected,
lookupState.tag,
resolvePendingAgent,
serverId,
workspaceKey,
]);
if (lookupState.tag === "not_found") {
return (
@@ -894,6 +924,7 @@ function ChatAgentContent({
routeKey: `${serverId}:${agentId ?? ""}`,
input: {
agent: agent ?? null,
isArchived: agentState.archivedAt !== null,
missingAgentState,
isConnected,
isArchivingCurrentAgent,
@@ -947,6 +978,9 @@ function ChatAgentContent({
if (!agentId) {
return;
}
if (agentState.archivedAt) {
return;
}
if (agentState.id && hasAppliedAuthoritativeHistory) {
if (
missingAgentState.kind === "resolving" ||
@@ -1012,6 +1046,7 @@ function ChatAgentContent({
});
}, [
agentState.id,
agentState.archivedAt,
hasAppliedAuthoritativeHistory,
agentId,
client,
@@ -2,6 +2,7 @@ import { useMemo } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatAmount, formatResetLabel } from "./format";
import { resolveTone } from "./tone";
import type { ProviderUsageBalance, ProviderUsageTone } from "./types";
interface ResolvedBalance {
@@ -41,7 +42,7 @@ function fillToneStyle(tone: ProviderUsageTone) {
export function ProviderUsageBalanceBar({ balance }: { balance: ProviderUsageBalance }) {
const { amountText, usedPct } = resolveBalance(balance);
const tone = balance.tone ?? "default";
const tone = resolveTone(balance.tone, usedPct);
const resetLabel = formatResetLabel(balance.resetsAt);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { resolveTone } from "./tone";
describe("provider usage tone", () => {
it("shows the strongest risk from provider status and utilization", () => {
expect([
resolveTone("ok", 5),
resolveTone("ok", 70),
resolveTone("ok", 99),
resolveTone("danger", 5),
]).toEqual(["ok", "warning", "danger", "danger"]);
});
});
+18
View File
@@ -1,8 +1,26 @@
import type { ProviderUsageTone } from "./types";
const TONE_RISK: Record<ProviderUsageTone, number> = {
default: 0,
ok: 1,
warning: 2,
danger: 3,
};
export function deriveTone(usedPct: number | null | undefined): ProviderUsageTone {
if (usedPct == null) return "default";
if (usedPct > 90) return "danger";
if (usedPct >= 70) return "warning";
return "default";
}
export function resolveTone(
providerTone: ProviderUsageTone | undefined,
usedPct: number | null | undefined,
): ProviderUsageTone {
const utilizationTone = deriveTone(usedPct);
if (!providerTone || TONE_RISK[utilizationTone] > TONE_RISK[providerTone]) {
return utilizationTone;
}
return providerTone;
}
@@ -2,7 +2,7 @@ import { useMemo } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatPct, formatResetLabel } from "./format";
import { deriveTone } from "./tone";
import { resolveTone } from "./tone";
import type { ProviderUsageTone, ProviderUsageWindow } from "./types";
function resolveUsedPct(window: ProviderUsageWindow): number | null {
@@ -26,7 +26,7 @@ function fillToneStyle(tone: ProviderUsageTone) {
export function ProviderUsageWindowBar({ window }: { window: ProviderUsageWindow }) {
const usedPct = resolveUsedPct(window);
const tone = window.tone ?? deriveTone(usedPct);
const tone = resolveTone(window.tone, usedPct);
const fillWidth = clampPct(usedPct ?? 0);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
@@ -291,11 +291,13 @@ interface WorkspaceScreenProps {
workspaceId: string;
isRouteFocused?: boolean;
recoveryRequested?: boolean;
recoveryAgentId?: string | null;
}
type WorkspaceScreenContentProps = WorkspaceScreenProps & {
isRouteFocused: boolean;
recoveryRequested: boolean;
recoveryAgentId: string | null;
};
function trimNonEmpty(value: string | null | undefined): string | null {
@@ -912,6 +914,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
workspaceId,
isRouteFocused,
recoveryRequested,
recoveryAgentId,
}: WorkspaceScreenProps) {
const navigationFocused = useIsFocused();
return (
@@ -920,6 +923,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
workspaceId={workspaceId}
isRouteFocused={isRouteFocused ?? navigationFocused}
recoveryRequested={recoveryRequested ?? false}
recoveryAgentId={recoveryAgentId ?? null}
/>
);
});
@@ -1715,6 +1719,7 @@ function WorkspaceScreenContent({
workspaceId,
isRouteFocused,
recoveryRequested,
recoveryAgentId,
}: WorkspaceScreenContentProps) {
const { t } = useTranslation();
const _insets = useSafeAreaInsets();
@@ -1791,6 +1796,7 @@ function WorkspaceScreenContent({
const workspaceRecovery = useWorkspaceRecovery({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
agentId: recoveryAgentId,
enabled: shouldInspectWorkspaceRecovery(
hasHydratedWorkspaces,
workspaceDescriptor,
@@ -194,6 +194,7 @@ interface ReorderPaneTabsInLayoutInput {
export interface WorkspaceTabReconcileState {
layout: WorkspaceLayout;
pinnedAgentIds?: ReadonlySet<string> | null;
pendingAgentIds?: ReadonlySet<string> | null;
hiddenAgentIds?: ReadonlySet<string> | null;
}
@@ -1653,13 +1654,14 @@ interface EntityTabGroup {
function applyPinnedAndHidden(input: {
baseAgentIds: Set<string>;
pinnedAgentIds: Set<string>;
pendingAgentIds: Set<string>;
hiddenAgentIds: Set<string>;
knownAgentIds: Set<string>;
}): Set<string> {
const { baseAgentIds, pinnedAgentIds, hiddenAgentIds, knownAgentIds } = input;
const { baseAgentIds, pinnedAgentIds, pendingAgentIds, hiddenAgentIds, knownAgentIds } = input;
const result = new Set(baseAgentIds);
for (const agentId of pinnedAgentIds) {
if (knownAgentIds.has(agentId)) {
if (knownAgentIds.has(agentId) || pendingAgentIds.has(agentId)) {
result.add(agentId);
}
}
@@ -1784,6 +1786,7 @@ export function reconcileWorkspaceTabs(
findPaneById(nextLayout.root, nextLayout.focusedPaneId)?.focusedTabId ?? null;
let reconciledFocusedTabId = originalFocusedTabId;
const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []);
const pendingAgentIds = new Set(state.pendingAgentIds ?? []);
const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []);
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds);
@@ -1795,12 +1798,14 @@ export function reconcileWorkspaceTabs(
const visibleAgentIds = applyPinnedAndHidden({
baseAgentIds: activeAgentIds,
pinnedAgentIds,
pendingAgentIds,
hiddenAgentIds,
knownAgentIds,
});
const autoOpenSet = applyPinnedAndHidden({
baseAgentIds: autoOpenAgentIds,
pinnedAgentIds,
pendingAgentIds,
hiddenAgentIds,
knownAgentIds,
});
@@ -1591,6 +1591,66 @@ describe("workspace-layout-store actions", () => {
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
});
it("keeps an explicitly pinned archived agent before its detail is hydrated", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "archived-agent" });
store.pinAgent(workspaceKey, "archived-agent");
store.reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
standaloneTerminalIds: [],
});
expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
"agent_archived-agent",
]);
store.reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
standaloneTerminalIds: [],
});
expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
"agent_archived-agent",
]);
store.resolvePendingAgent(workspaceKey, "archived-agent");
store.reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
standaloneTerminalIds: [],
});
expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([]);
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "archived-agent" });
store.pinAgent(workspaceKey, "archived-agent");
store.reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
standaloneTerminalIds: [],
});
expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
"agent_archived-agent",
]);
});
it("retargeting a tab to an agent clears hidden intent", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
@@ -75,6 +75,7 @@ interface WorkspaceLayoutStore {
layoutByWorkspace: Record<string, WorkspaceLayout>;
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
pendingAgentIdsByWorkspace: Record<string, Set<string>>;
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
@@ -89,6 +90,7 @@ interface WorkspaceLayoutStore {
retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null;
convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null;
reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void;
resolvePendingAgent: (workspaceKey: string, agentId: string) => void;
reorderTabs: (workspaceKey: string, tabIds: string[]) => void;
getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[];
splitPane: (
@@ -229,6 +231,7 @@ export function createWorkspaceLayoutStore(
layoutByWorkspace: {},
splitSizesByWorkspace: {},
pinnedAgentIdsByWorkspace: {},
pendingAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {},
openTabFocused: (workspaceKey, target) => {
@@ -467,6 +470,7 @@ export function createWorkspaceLayoutStore(
{
layout: currentLayout,
pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
},
snapshot,
@@ -483,6 +487,25 @@ export function createWorkspaceLayoutStore(
};
});
},
resolvePendingAgent: (workspaceKey, agentId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedAgentId = trimNonEmpty(agentId);
if (!normalizedWorkspaceKey || !normalizedAgentId) {
return;
}
set((state) => {
const pendingAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet(
state.pendingAgentIdsByWorkspace,
normalizedWorkspaceKey,
normalizedAgentId,
);
if (pendingAgentIdsByWorkspace === state.pendingAgentIdsByWorkspace) {
return state;
}
return { pendingAgentIdsByWorkspace };
});
},
reorderTabs: (workspaceKey, tabIds) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) {
@@ -762,7 +785,12 @@ export function createWorkspaceLayoutStore(
set((state) => {
const currentPinnedAgentIds =
state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null;
if (currentPinnedAgentIds?.has(normalizedAgentId)) {
const currentPendingAgentIds =
state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null;
if (
currentPinnedAgentIds?.has(normalizedAgentId) &&
currentPendingAgentIds?.has(normalizedAgentId)
) {
return state;
}
@@ -779,6 +807,11 @@ export function createWorkspaceLayoutStore(
...state.pinnedAgentIdsByWorkspace,
[normalizedWorkspaceKey]: nextPinnedAgentIds,
},
pendingAgentIdsByWorkspace: addAgentIdToWorkspaceSet(
state.pendingAgentIdsByWorkspace,
normalizedWorkspaceKey,
normalizedAgentId,
),
};
});
},
@@ -803,6 +836,11 @@ export function createWorkspaceLayoutStore(
delete nextPinnedAgentIdsByWorkspace[normalizedWorkspaceKey];
return {
pinnedAgentIdsByWorkspace: nextPinnedAgentIdsByWorkspace,
pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet(
state.pendingAgentIdsByWorkspace,
normalizedWorkspaceKey,
normalizedAgentId,
),
};
}
@@ -814,6 +852,11 @@ export function createWorkspaceLayoutStore(
...state.pinnedAgentIdsByWorkspace,
[normalizedWorkspaceKey]: nextPinnedAgentIds,
},
pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet(
state.pendingAgentIdsByWorkspace,
normalizedWorkspaceKey,
normalizedAgentId,
),
};
});
},
@@ -872,6 +915,7 @@ export function createWorkspaceLayoutStore(
normalizedWorkspaceKey in state.layoutByWorkspace ||
normalizedWorkspaceKey in state.splitSizesByWorkspace ||
normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace ||
normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace ||
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace ||
normalizedWorkspaceKey in state.focusRestorationByWorkspace;
if (!hasAny) {
@@ -883,6 +927,8 @@ export function createWorkspaceLayoutStore(
state.splitSizesByWorkspace;
const { [normalizedWorkspaceKey]: _pinned, ...pinnedAgentIdsByWorkspace } =
state.pinnedAgentIdsByWorkspace;
const { [normalizedWorkspaceKey]: _pending, ...pendingAgentIdsByWorkspace } =
state.pendingAgentIdsByWorkspace;
const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } =
state.hiddenAgentIdsByWorkspace;
const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } =
@@ -891,6 +937,7 @@ export function createWorkspaceLayoutStore(
layoutByWorkspace,
splitSizesByWorkspace,
pinnedAgentIdsByWorkspace,
pendingAgentIdsByWorkspace,
hiddenAgentIdsByWorkspace,
focusRestorationByWorkspace,
};
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveWorkspaceRecoveryModel } from "./model";
import { recoverWorkspaceSelection, resolveWorkspaceRecoveryModel } from "./model";
describe("resolveWorkspaceRecoveryModel", () => {
it("keeps newer recovery actions visible but non-actionable", () => {
@@ -29,3 +29,24 @@ describe("resolveWorkspaceRecoveryModel", () => {
});
});
});
describe("recoverWorkspaceSelection", () => {
it("restores the workspace and selected archived agent as one recovery action", async () => {
const operations: string[] = [];
await recoverWorkspaceSelection({
workspaceId: "workspace-1",
agentId: "agent-1",
client: {
restoreWorkspace: async (workspaceId) => {
operations.push(`workspace:${workspaceId}`);
},
refreshAgent: async (agentId) => {
operations.push(`agent:${agentId}`);
},
},
});
expect(operations).toEqual(["workspace:workspace-1", "agent:agent-1"]);
});
});
@@ -34,6 +34,22 @@ export interface WorkspaceRecoveryController {
retryInspection: () => void;
}
export interface WorkspaceSelectionRecoveryClient {
restoreWorkspace: (workspaceId: string) => Promise<unknown>;
refreshAgent: (agentId: string) => Promise<unknown>;
}
export async function recoverWorkspaceSelection(input: {
client: WorkspaceSelectionRecoveryClient;
workspaceId: string;
agentId?: string | null;
}): Promise<void> {
await input.client.restoreWorkspace(input.workspaceId);
if (input.agentId) {
await input.client.refreshAgent(input.agentId);
}
}
function resolveRecoveryPhase(input: {
pending: boolean;
error: string | null;
@@ -4,7 +4,11 @@ import { useFetchQuery } from "@/data/query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { toErrorMessage } from "@/utils/error-messages";
import { resolveWorkspaceRecoveryModel, type WorkspaceRecoveryController } from "./model";
import {
recoverWorkspaceSelection,
resolveWorkspaceRecoveryModel,
type WorkspaceRecoveryController,
} from "./model";
export type { WorkspaceRecoveryController, WorkspaceRecoveryModel } from "./model";
@@ -28,6 +32,7 @@ function waitForMinimumRecoveryLoadingTime(): Promise<void> {
export function useWorkspaceRecovery(input: {
serverId: string;
workspaceId: string;
agentId?: string | null;
enabled: boolean;
}): WorkspaceRecoveryController {
const client = useHostRuntimeClient(input.serverId);
@@ -56,7 +61,11 @@ export function useWorkspaceRecovery(input: {
}
await waitForRecoveryLoadingPresentation();
await waitForMinimumRecoveryLoadingTime();
await client.restoreWorkspace(input.workspaceId);
await recoverWorkspaceSelection({
client,
workspaceId: input.workspaceId,
agentId: input.agentId,
});
},
});
+16 -3
View File
@@ -10,10 +10,23 @@ describe("canonical CLI surface", () => {
expect(help).not.toContain("worktree");
});
it("hides legacy run worktree syntax", () => {
it("names explicit workspace creation without exposing older syntax", () => {
const run = createCli().commands.find((command) => command.name() === "run");
expect(run?.helpInformation()).toContain("--isolation <local|worktree>");
expect(run?.helpInformation()).not.toContain("--worktree <name>");
const help = run?.helpInformation();
expect(help).toContain("--new-workspace <local|worktree>");
expect(help).not.toContain("--isolation");
expect(help).not.toContain("--worktree <name>");
});
it("offers the worktree creation options on run", () => {
const run = createCli().commands.find((command) => command.name() === "run");
const help = run?.helpInformation();
expect(help).toContain("--worktree-mode <mode>");
expect(help).toContain("--worktree-slug <slug>");
expect(help).toContain("--new-branch <name>");
expect(help).toContain("--branch <name>");
expect(help).toContain("--pr-number <n>");
expect(help).toContain("--forge <forge>");
});
it("uses background for execution and reserves detach for ownership", () => {
+22 -8
View File
@@ -72,23 +72,37 @@ describe("runRunCommand option validation", () => {
});
}
it("rejects --isolation combined with --workspace", async () => {
it("rejects --new-workspace combined with --workspace", async () => {
await expectInvalidOptions(
{ isolation: "worktree", workspace: "ws-1" },
/--isolation and --workspace cannot be combined/,
{ newWorkspace: "worktree", workspace: "ws-1" },
/--new-workspace and --workspace cannot be combined/,
);
});
it("allows explicit worktree isolation through validation", async () => {
// Explicit isolation with no --workspace
it("allows explicit worktree workspace creation through validation", async () => {
// Explicit workspace creation with no --workspace
// must clear validation. It still fails later (provider resolution), which
// is enough to prove the new guard did not reject it.
await expect(
runRunCommand("do something", { isolation: "worktree", provider: undefined }, {} as never),
runRunCommand("do something", { newWorkspace: "worktree", provider: undefined }, {} as never),
).rejects.not.toMatchObject({ code: "INVALID_OPTIONS" });
});
it("rejects unknown workspace isolation", async () => {
await expectInvalidOptions({ isolation: "container" }, /Unsupported workspace isolation/);
it("rejects unknown new workspace kinds", async () => {
await expectInvalidOptions({ newWorkspace: "container" }, /Unsupported new workspace kind/);
});
it("rejects two workspace creation flags", async () => {
await expectInvalidOptions(
{ newWorkspace: "local", worktree: "legacy-slug" },
/--new-workspace and --worktree cannot be combined/,
);
});
it("rejects an unknown worktree creation mode before connecting", async () => {
await expectInvalidOptions(
{ newWorkspace: "worktree", worktreeMode: "container" },
/Unsupported worktree mode/,
);
});
});
+98 -38
View File
@@ -14,6 +14,7 @@ import { lookup } from "mime-types";
import { parseDuration } from "../../utils/duration.js";
import { collectMultiple } from "../../utils/command-options.js";
import { resolveProviderAndModel } from "../../utils/provider-model.js";
import { buildWorkspaceSource } from "../workspace/create.js";
export { resolveProviderAndModel } from "../../utils/provider-model.js";
@@ -38,12 +39,21 @@ export function addRunOptions(cmd: Command): Command {
)
.option("--thinking <id>", "Thinking option ID to use for this run")
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
.option("--isolation <local|worktree>", "Create a new workspace with this isolation")
.option("--new-workspace <local|worktree>", "Create a separate local or worktree workspace")
.addOption(new Option("--worktree <name>", "Legacy workspace isolation alias").hideHelp())
.option("--base <branch>", "Base branch for an isolated workspace")
.option(
"--worktree-mode <mode>",
"Worktree mode: branch-off, checkout-branch, or checkout-pr",
)
.option("--worktree-slug <slug>", "Managed worktree path slug")
.option("--new-branch <name>", "New branch name for branch-off mode")
.option("--base <ref>", "Base ref for branch-off mode")
.option("--branch <name>", "Existing branch for checkout-branch mode")
.option("--pr-number <n>", "Pull request or change request number for checkout-pr mode")
.option("--forge <forge>", "Forge for checkout-pr mode")
.option(
"--workspace <id>",
"Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)",
"Run in an existing workspace (defaults to the caller workspace when agent-scoped)",
)
.option(
"--image <path>",
@@ -105,9 +115,15 @@ export interface AgentRunOptions extends CommandOptions {
model?: string;
thinking?: string;
mode?: string;
isolation?: string;
newWorkspace?: string;
worktree?: string;
worktreeMode?: string;
worktreeSlug?: string;
newBranch?: string;
base?: string;
branch?: string;
prNumber?: string;
forge?: string;
workspace?: string;
image?: string[];
cwd?: string;
@@ -117,6 +133,25 @@ export interface AgentRunOptions extends CommandOptions {
outputSchema?: string;
}
function resolveNewWorkspaceKind(options: AgentRunOptions): string | undefined {
return options.newWorkspace ?? (options.worktree ? "worktree" : undefined);
}
function buildRunWorkspaceSource(options: AgentRunOptions, cwd: string) {
const newWorkspace = resolveNewWorkspaceKind(options) ?? "local";
return buildWorkspaceSource({
isolation: newWorkspace,
path: cwd,
mode: options.worktreeMode,
worktreeSlug: options.worktreeSlug ?? options.worktree,
newBranch: options.newBranch,
base: options.base,
branch: options.branch,
prNumber: options.prNumber,
forge: options.forge,
});
}
function toRunResult(
agent: AgentSnapshotPayload,
statusOverride?: AgentRunResult["status"],
@@ -269,37 +304,61 @@ function structuredRunSchema(output: Record<string, unknown>): OutputSchema<Agen
};
}
function validateRunOptions(prompt: string, options: AgentRunOptions, outputSchema: unknown): void {
if (!prompt || prompt.trim().length === 0) {
function validateRunWorkspaceOptions(options: AgentRunOptions): void {
const newWorkspace = resolveNewWorkspaceKind(options);
if (
options.newWorkspace &&
options.newWorkspace !== "local" &&
options.newWorkspace !== "worktree"
) {
throw {
code: "MISSING_PROMPT",
message: "A prompt is required",
details: "Usage: paseo agent run [options] <prompt>",
code: "INVALID_OPTIONS",
message: `Unsupported new workspace kind: ${options.newWorkspace}`,
details: "Use --new-workspace local or --new-workspace worktree",
} satisfies CommandError;
}
const createsIsolatedWorkspace = options.isolation === "worktree" || Boolean(options.worktree);
if (options.isolation && options.isolation !== "local" && options.isolation !== "worktree") {
if (options.newWorkspace && options.worktree) {
throw {
code: "INVALID_OPTIONS",
message: `Unsupported workspace isolation: ${options.isolation}`,
details: "Use --isolation local or --isolation worktree",
message: "--new-workspace and --worktree cannot be combined",
details: "Use --new-workspace worktree and the supported worktree options",
} satisfies CommandError;
}
if (options.base && !createsIsolatedWorkspace) {
const hasWorktreeCreationOptions = [
options.worktreeMode,
options.worktreeSlug,
options.newBranch,
options.base,
options.branch,
options.prNumber,
options.forge,
].some((value) => value !== undefined);
if (hasWorktreeCreationOptions && newWorkspace !== "worktree") {
throw {
code: "INVALID_OPTIONS",
message: "--base can only be used with --isolation worktree",
details: "Usage: paseo agent run --isolation worktree --base <branch> <prompt>",
message: "Worktree options require --new-workspace worktree",
details: "Usage: paseo run --new-workspace worktree [worktree options] <prompt>",
} satisfies CommandError;
}
if (options.isolation && options.workspace) {
if (newWorkspace === "worktree") {
try {
buildRunWorkspaceSource(options, options.cwd ?? process.cwd());
} catch (error) {
throw {
code: "INVALID_OPTIONS",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
if (options.newWorkspace && options.workspace) {
throw {
code: "INVALID_OPTIONS",
message: "--isolation and --workspace cannot be combined",
details: "Select an existing workspace or create a new one with an isolation choice",
message: "--new-workspace and --workspace cannot be combined",
details: "Select an existing workspace or explicitly create a new one",
} satisfies CommandError;
}
@@ -309,9 +368,21 @@ function validateRunOptions(prompt: string, options: AgentRunOptions, outputSche
throw {
code: "INVALID_OPTIONS",
message: "--worktree and --workspace cannot be combined",
details: "Use --isolation worktree instead of the legacy --worktree flag",
details: "Use --new-workspace worktree instead of the legacy --worktree flag",
} satisfies CommandError;
}
}
function validateRunOptions(prompt: string, options: AgentRunOptions, outputSchema: unknown): void {
if (!prompt || prompt.trim().length === 0) {
throw {
code: "MISSING_PROMPT",
message: "A prompt is required",
details: "Usage: paseo agent run [options] <prompt>",
} satisfies CommandError;
}
validateRunWorkspaceOptions(options);
if (outputSchema && runsInBackground(options)) {
throw {
@@ -465,27 +536,25 @@ export async function resolveExistingRunWorkspace(
// 1. --workspace <id> -> run in that existing workspace
// 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace
// 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals
// 4. --isolation <kind> -> mint a new workspace with explicit isolation
// 4. --new-workspace <kind> -> mint a new workspace explicitly
// 5. bare run -> mint a new local-backed workspace for cwd
async function resolveRunWorkspace(
client: ConnectedDaemonClient,
options: AgentRunOptions,
cwd: string,
): Promise<RunWorkspace> {
const requestedIsolation = options.isolation ?? (options.worktree ? "worktree" : undefined);
const explicit = requestedIsolation ? undefined : options.workspace?.trim();
const newWorkspace = resolveNewWorkspaceKind(options);
const explicit = newWorkspace ? undefined : options.workspace?.trim();
if (explicit) {
console.error(`Using workspace ${explicit}`);
return resolveExistingRunWorkspace(client, explicit);
}
if (!requestedIsolation && resolveRunCallerAgentId()) {
if (!newWorkspace && resolveRunCallerAgentId()) {
return { cwd };
}
const ambientWorkspaceId = requestedIsolation
? undefined
: process.env.PASEO_WORKSPACE_ID?.trim();
const ambientWorkspaceId = newWorkspace ? undefined : process.env.PASEO_WORKSPACE_ID?.trim();
if (ambientWorkspaceId) {
console.error(`Using workspace ${ambientWorkspaceId}`);
return resolveExistingRunWorkspace(client, ambientWorkspaceId);
@@ -493,17 +562,8 @@ async function resolveRunWorkspace(
// TODO: thread the run `prompt` as firstAgentContext so workspace-level
// title/branch generation picks up the task description (U8/U6 deferred).
const result =
requestedIsolation === "worktree"
? await client.createWorkspace({
source: {
kind: "worktree",
cwd,
worktreeSlug: options.worktree,
baseBranch: options.base,
},
})
: await client.createWorkspace({ source: { kind: "directory", path: cwd } });
const source = buildRunWorkspaceSource(options, cwd);
const result = await client.createWorkspace({ source });
if (!result.workspace) {
throw {
+24
View File
@@ -58,6 +58,8 @@ try {
assert(result.stdout.includes("--title"), "help should mention --title option");
assert(result.stdout.includes("--provider"), "help should mention --provider option");
assert(result.stdout.includes("--mode"), "help should mention --mode option");
assert(result.stdout.includes("--new-workspace"), "help should mention --new-workspace option");
assert(!result.stdout.includes("--isolation"), "help should not mention --isolation");
assert(result.stdout.includes("--cwd"), "help should mention --cwd option");
assert(result.stdout.includes("--output-schema"), "help should mention --output-schema option");
assert(result.stdout.includes("--host"), "help should mention --host option");
@@ -244,6 +246,28 @@ try {
assert(output.includes("unknown option"), "should report unknown option for --ui");
console.log("✓ run --ui is rejected\n");
}
// Test 15: run --new-workspace is accepted
{
console.log("Test 15: run --new-workspace is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --new-workspace local "test prompt"`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --new-workspace");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ run --new-workspace is accepted\n");
}
// Test 16: run --isolation is rejected (unreleased flag removed)
{
console.log("Test 16: run --isolation is rejected");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --isolation local "test prompt"`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail for removed --isolation flag");
const output = result.stdout + result.stderr;
assert(output.includes("unknown option"), "should report unknown option for --isolation");
console.log("✓ run --isolation is rejected\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true });
@@ -130,7 +130,13 @@ function shellQuoteCliArg(value) {
function getTerminalHookSmokeCommand(marker) {
if (process.platform === "win32") {
return `"%PASEO_HOOK_CLI%" hooks codex Stop && echo ${marker}`;
const script = [
"& $env:PASEO_HOOK_CLI hooks codex Stop",
"if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }",
`Write-Output '${marker}'`,
].join("; ");
const encodedScript = Buffer.from(script, "utf16le").toString("base64");
return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodedScript}`;
}
return `"$PASEO_HOOK_CLI" hooks codex Stop && echo ${marker}`;
@@ -11,7 +11,12 @@ import {
toAgentPersistenceHandle,
} from "../persistence-hooks.js";
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
interface PendingAgentInitialization {
promise: Promise<ManagedAgent>;
options: { broadcastTimeline: boolean };
}
const pendingAgentInitializations = new Map<string, PendingAgentInitialization>();
export type AgentLoaderManager = Pick<
AgentManager,
@@ -27,6 +32,7 @@ export interface EnsureAgentLoadedDeps {
agentManager: AgentLoaderManager;
agentStorage: AgentStorage;
validProviders?: Iterable<AgentProvider>;
broadcastTimeline?: boolean;
logger: Logger;
}
@@ -58,6 +64,13 @@ export async function ensureAgentLoaded(
deps: EnsureAgentLoadedDeps,
): Promise<ManagedAgent> {
await deps.agentManager.waitForAgentClose?.(agentId);
const inflight = pendingAgentInitializations.get(agentId);
if (inflight) {
inflight.options.broadcastTimeline ||= deps.broadcastTimeline === true;
return inflight.promise;
}
const existing =
deps.agentManager.touchAgentActivity?.(agentId) ?? deps.agentManager.getAgent(agentId);
if (existing) {
@@ -69,11 +82,15 @@ export async function ensureAgentLoaded(
// before storage-backed resume begins.
await deps.agentManager.waitForAgentClose?.(agentId);
const inflight = pendingAgentInitializations.get(agentId);
if (inflight) {
return inflight;
const laterInflight = pendingAgentInitializations.get(agentId);
if (laterInflight) {
laterInflight.options.broadcastTimeline ||= deps.broadcastTimeline === true;
return laterInflight.promise;
}
const pendingOptions = {
broadcastTimeline: deps.broadcastTimeline === true,
};
const initPromise = (async () => {
const record = await deps.agentStorage.get(agentId);
if (!record) {
@@ -111,17 +128,20 @@ export async function ensureAgentLoaded(
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
}
await deps.agentManager.hydrateTimelineFromProvider(agentId);
await deps.agentManager.hydrateTimelineFromProvider(agentId, {
broadcast: () => pendingOptions.broadcastTimeline,
});
return deps.agentManager.getAgent(agentId) ?? snapshot;
})();
pendingAgentInitializations.set(agentId, initPromise);
const pending: PendingAgentInitialization = { promise: initPromise, options: pendingOptions };
pendingAgentInitializations.set(agentId, pending);
try {
return await initPromise;
} finally {
const current = pendingAgentInitializations.get(agentId);
if (current === initPromise) {
if (current === pending) {
pendingAgentInitializations.delete(agentId);
}
}
@@ -7518,6 +7518,76 @@ test("ensureUnarchivedAgentLoaded fences an archived agent after joining a share
}
});
test("a shared agent load upgrades provider history hydration to broadcast", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-shared-load-broadcast-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const historyStarted = deferred<void>();
const historyAllowed = deferred<void>();
const client = new (class extends TestAgentClient {
override async resumeSession(
_handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
return new (class extends TestAgentSession {
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
historyStarted.resolve();
await historyAllowed.promise;
yield {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "Recovered history" },
};
}
})({ provider: "codex", cwd: config?.cwd ?? workdir });
}
})();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await manager.deleteAgentState(agent.id);
const events: AgentManagerEvent[] = [];
manager.subscribe((event) => events.push(event), { agentId: agent.id, replayState: false });
const quietLoad = ensureAgentLoaded(agent.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
await historyStarted.promise;
const broadcastingLoad = ensureAgentLoaded(agent.id, {
agentManager: manager,
agentStorage: storage,
broadcastTimeline: true,
logger,
});
historyAllowed.resolve();
await Promise.all([quietLoad, broadcastingLoad]);
expect(events).toContainEqual(
expect.objectContaining({
type: "agent_stream",
agentId: agent.id,
event: expect.objectContaining({
type: "timeline",
item: { type: "assistant_message", text: "Recovered history" },
}),
}),
);
} finally {
historyAllowed.resolve();
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("collectIdleAgents leaves recent, protected, internal, running, and error agents resident", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-eligibility-"));
const client = new (class extends TestAgentClient {
@@ -185,7 +185,7 @@ export interface SubscribeOptions {
interface HydrateTimelineOptions {
force?: boolean;
broadcast?: boolean;
broadcast?: boolean | (() => boolean);
}
export type ImportablePersistedAgentQueryOptions = ListImportableSessionsOptions & {
@@ -3177,12 +3177,17 @@ export class AgentManager {
return;
}
const broadcast = options?.broadcast ?? false;
if (options?.force) {
await this.forceHydrateTimelineFromLegacyProviderHistory(agent, options.broadcast === true);
await this.forceHydrateTimelineFromLegacyProviderHistory(
agent,
typeof broadcast === "function" ? broadcast() : broadcast,
);
return;
}
await this.primeTimelineFromLegacyProviderHistory(agent, options?.broadcast === true);
await this.primeTimelineFromLegacyProviderHistory(agent, broadcast);
}
private async forceHydrateTimelineFromLegacyProviderHistory(
@@ -3239,15 +3244,24 @@ export class AgentManager {
private async primeTimelineFromLegacyProviderHistory(
agent: ActiveManagedAgent,
broadcast: boolean,
broadcast: boolean | (() => boolean),
): Promise<void> {
const deferredBroadcast = typeof broadcast === "function";
const timelineEvents: Array<{
event: Extract<AgentStreamEvent, { type: "timeline" }>;
row: AgentTimelineRow;
}> = [];
const providerSubagentEvents: AgentManagerEvent[] = [];
agent.historyPrimed = true;
try {
for await (const event of agent.session.streamHistory()) {
if (event.type === "provider_subagent") {
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
if (broadcast) {
this.dispatch({ type: "provider_subagent", event: update });
const managerEvent: AgentManagerEvent = { type: "provider_subagent", event: update };
if (deferredBroadcast) {
providerSubagentEvents.push(managerEvent);
} else if (broadcast) {
this.dispatch(managerEvent);
}
continue;
}
@@ -3257,15 +3271,38 @@ export class AgentManager {
if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) {
continue;
}
this.recordTimeline(
const row = this.recordTimeline(
agent.id,
event.item,
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (deferredBroadcast) {
timelineEvents.push({ event, row });
} else if (broadcast) {
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
}
}
} catch {
// ignore history failures
}
if (typeof broadcast !== "function" || !broadcast()) {
return;
}
for (const event of providerSubagentEvents) {
this.dispatch(event);
}
for (const { event, row } of timelineEvents) {
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
}
}
private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void {
@@ -5632,6 +5632,7 @@ describe("agent snapshot MCP serialization", () => {
expect(spies.agentManager.resumeAgentFromPersistence).toHaveBeenCalled();
expect(spies.agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(
"archived-activity-agent",
{ broadcast: expect.any(Function) },
);
});
+11 -14
View File
@@ -30,12 +30,7 @@ import { CursorError } from "./pagination/cursor.js";
import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js";
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
import {
buildConfigOverrides,
extractTimestamps,
isStoredAgentProviderAvailable,
toAgentPersistenceHandle,
} from "./persistence-hooks.js";
import { isStoredAgentProviderAvailable, toAgentPersistenceHandle } from "./persistence-hooks.js";
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent/agent-loading.js";
import {
formatSystemNotificationPrompt,
@@ -3183,16 +3178,18 @@ export class Session {
if (!isStoredAgentProviderAvailable(record, registeredProviderIds)) {
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
}
const handle = toAgentPersistenceHandle(registeredProviderIds, record.persistence);
if (!handle) {
if (!toAgentPersistenceHandle(registeredProviderIds, record.persistence)) {
throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`);
}
snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
agentId,
extractTimestamps(record),
);
// Share the loader's per-agent in-flight operation with timeline fetches.
// Unarchiving publishes the record before provider resume finishes, so
// the agent pane can otherwise race this request and resume it twice.
snapshot = await ensureAgentLoaded(agentId, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
broadcastTimeline: true,
logger: this.sessionLogger,
});
}
await this.agentManager.hydrateTimelineFromProvider(agentId, { broadcast: true });
await this.agentUpdates.forwardLiveAgent(snapshot);
+11 -39
View File
@@ -1,6 +1,6 @@
---
title: Claude Code
description: How Paseo runs Claude Code and how Anthropic's usage policy applies.
description: Run Claude Code in Paseo using your existing Claude plan.
nav: Claude Code
order: 23
category: Providers
@@ -8,54 +8,26 @@ category: Providers
# Claude Code
Paseo runs Claude Code through the official `claude` CLI using the same Claude Agent SDK that Claude Desktop uses internally.
Paseo runs Claude Code through the official `claude` CLI using the Claude Agent SDK.
## Anthropic's June 15, 2026 policy change
## Does Claude Code cost extra in Paseo?
Starting June 15, 2026, Anthropic splits Claude Code usage into two buckets. This is documented in their official support article: ["Use the Claude Agent SDK with your Claude plan"](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan).
No. Claude Code usage in Paseo counts against your normal Claude plan limits. It does not require a separate pool of Agent SDK credits.
**Interactive usage** — draws from your main subscription limits:
You still need a Claude plan that includes Claude Code, and your plan's usual usage limits apply.
- Claude Code in your terminal
- Claude Code in VS Code, JetBrains, and other IDEs
- Claude Desktop (Anthropic's own app)
- Claude chat on web, mobile, and desktop
## Getting started
**Programmatic usage** — draws from a separate monthly credit pool:
Install and sign in to the Claude Code CLI on the machine running Paseo. Paseo uses that existing installation and account when you start a Claude Code agent.
- `claude -p` (non-interactive/scripting mode)
- Claude Agent SDK usage in your own projects
- Claude Code GitHub Actions integration
- Third-party apps that authenticate through the Agent SDK
## Use Claude Code in the Paseo terminal
Credit amounts per month: Pro ($20), Max 5x ($100), Max 20x ($200), Team Standard ($20/seat), Team Premium ($100/seat). Credits don't roll over. After they run out, additional usage flows to pay-as-you-go API rates (if you have usage credits enabled).
Claude Code also works great inside the Paseo terminal. If you prefer the standard CLI experience, open a terminal in your workspace and run `claude` as usual.
## Where Paseo fits
Paseo uses the **Claude Agent SDK** to run Claude Code — the exact same mechanism Claude Desktop uses under the hood. Claude Desktop is whitelisted by Anthropic and counts as interactive usage. Paseo is not.
Even though the usage is practically interactive — you type prompts, review output, and approve tool calls in real time — Anthropic classifies Paseo as "programmatic usage" because it is a third-party app that authenticates through the Agent SDK.
## What this means for you
- Your **interactive** Claude Code usage (terminal, IDE, Claude Desktop) continues to draw from your main subscription limits, unchanged.
- Your **Paseo chat** usage draws from the separate Agent SDK monthly credits.
- Using Claude Code inside a Paseo terminal draws from your main subscription limits, same as any terminal.
## You can still use the terminal
Paseo has first-class terminal support. You can run Claude Code in your terminal exactly as you always have, and Paseo will still give you:
- **Worktree management** — create and switch git worktrees from the app
- **Remote access** — connect to your daemon from mobile or web while your terminal session runs locally
- **Git diffs** — review changes in a visual diff viewer
- **GitHub integration** — commit, push, open PRs, watch checks, and merge from the app
- **Agent supervision** — see all running agents, send follow-up prompts, and review output history
- **Relay** — access your terminal session from anywhere without exposing your machine
You can use the terminal from Paseo's desktop, web, or mobile app while keeping access to your workspace, git changes, and other Paseo tools.
## See also
- [Anthropic: Use the Claude Agent SDK with your Claude plan](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan)
- [Custom providers](/docs/custom-providers), for custom binaries, third-party endpoints, or multiple Claude profiles.
- [Supported providers](/docs/supported-providers), for other agents you can run alongside Claude Code.
- [Custom providers](/docs/custom-providers), for custom binaries, third-party endpoints, or multiple Claude profiles.
- [Paseo vs Claude Desktop](/alternatives/claude-desktop), for a feature comparison.
+4 -2
View File
@@ -31,13 +31,15 @@ Use `paseo run` to start a new agent with a task:
paseo run "implement user authentication"
paseo run --provider codex "refactor the API layer"
paseo run --background "run the focused test suite"
paseo run --isolation worktree --base main "implement feature X"
paseo run --new-workspace worktree --worktree-mode branch-off --new-branch feature/x --base main "implement feature X"
paseo run --workspace <workspace-id> "review the current diff"
paseo run --output-schema schema.json "extract release notes"
paseo run --output-schema '{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}' "summarize release notes"
```
From a human shell, a bare `paseo run` creates a new local workspace for the current directory. Use `--workspace <id>` to add the agent to an existing workspace, or `--isolation worktree` to create a new workspace backed by an isolated git worktree.
From a human shell, a bare `paseo run` creates a new local workspace for the current directory. Use `--workspace <id>` to add the agent to an existing workspace, or `--new-workspace local|worktree` to explicitly create a separate workspace for the run.
Worktree creation accepts `--worktree-mode branch-off|checkout-branch|checkout-pr` plus the matching `--new-branch`/`--base`, `--branch`, or `--pr-number`/`--forge` options. Use `--worktree-slug` to choose the managed directory slug.
When an existing Paseo agent runs the same command, Paseo recognizes it through `PASEO_AGENT_ID`. Without explicit placement, the new agent becomes its subagent in the same workspace. `--workspace` can place that subagent elsewhere without changing its parent.
+1 -1
View File
@@ -256,4 +256,4 @@ paseo run --workspace <workspace-id> "implement auth"
paseo workspace archive <workspace-id>
```
For the common case, `paseo run --isolation worktree --base main "implement auth"` creates both the workspace and its first agent.
For the common case, `paseo run --new-workspace worktree --worktree-mode branch-off --new-branch feature/auth --base main "implement auth"` creates both the workspace and its first agent.
+1
View File
@@ -104,6 +104,7 @@ paseo workspace create --isolation worktree --mode branch-off --new-branch fix-x
paseo workspace create --isolation worktree --mode checkout-branch --branch existing-work
paseo workspace create --isolation worktree --mode checkout-pr --pr-number 42
paseo run --provider codex/gpt-5.4 --mode full-access --workspace <workspace-id> "<prompt>"
paseo run --provider codex/gpt-5.4 --mode full-access --new-workspace worktree --worktree-mode branch-off --new-branch fix-x --base main "<prompt>"
paseo send <agent-id> "<follow-up>"
paseo ls
paseo schedule create --cron "*/15 * * * *" "ping main build"