mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf4afc3634 | ||
|
|
42ee5a5949 | ||
|
|
68993b7ab3 | ||
|
|
246c07fba5 | ||
|
|
0afcc96370 | ||
|
|
3a8d08ba91 | ||
|
|
f22a8ea8e0 |
+5
-5
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user