Make workspace, agent, and schedule automation consistent (#2186)

* feat(workspaces): align agent and schedule automation

Make workspace identity the shared placement boundary for MCP and CLI, while caller identity determines parentage. Keep heartbeats minimal and cron-based without changing legacy rolling interval semantics.

* test(cli): expect canonical schedule cadence

* fix(automation): preserve workspace and schedule compatibility

* fix(automation): preserve compatibility edges

* fix(workspaces): align MCP lifecycle resolution

* fix(workspaces): preserve creation intent

* fix(workspaces): preserve branch and schedule identity
This commit is contained in:
Mohamed Boudra
2026-07-18 14:04:48 +02:00
committed by GitHub
parent 98f6611362
commit ffe76a7e57
66 changed files with 2791 additions and 924 deletions

View File

@@ -18,21 +18,14 @@ Cancellation changes lifecycle state only after the provider acknowledges the in
## Relationships
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions:
- `relationship` decides whether the new agent belongs under the caller.
- `workspace` decides where the new agent lives and whether a new workspace/worktree is created.
`relationship: { kind: "subagent" }` stamps the created agent with `paseo.parent-agent-id`, pointing back at the creating agent. The client surfaces that as `agent.parentAgentId`. This requires an agent-scoped MCP session.
`relationship: { kind: "detached" }` creates a sibling/root agent (e.g. handoffs, fire-and-forget delegations). The daemon may still use the creating agent for cwd/config inheritance, but it does not write `paseo.parent-agent-id`.
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous and always stamps `paseo.parent-agent-id`, pointing back at the caller. Omit `workspaceId` to use the caller's workspace, or pass an existing workspace ID returned by `create_workspace`. Placement never changes parentage.
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — stand on their own, do not appear in the creating agent's subagent track, and are not archived with it.
- **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it.
`workspace: { kind: "current" }` uses the caller's workspace and can optionally override the runtime cwd. It requires an agent-scoped MCP session. `workspace: { kind: "create", source: { kind: "directory" | "worktree", ... } }` creates a new workspace for the new agent; worktree creation goes through the Paseo worktree workflow and stamps the agent with that fresh workspace id.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace.
Users can also detach an existing subagent from the subagents track. Detach removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
@@ -46,7 +39,7 @@ The provider still owns the underlying runtime. Paseo keeps an agent record so t
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created a Paseo worktree through its `worktree` field, auto-archive archives that worktree too, which removes the agent records inside the worktree.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
@@ -139,11 +132,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
Each agent is a single JSON file. Fields relevant to this doc:
| Field | Type | Meaning |
| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` when `relationship.kind === "subagent"` |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
| Field | Type | Meaning |
| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
See [`docs/data-model.md`](./data-model.md) for the full agent record.

View File

@@ -64,20 +64,20 @@ not retain non-Git directories.
**Key modules:**
| Module | Responsibility |
| ------------------------------- | ---------------------------------------------------------------------------- |
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/tools/` | Transport-neutral Paseo tool catalog for subagents, permissions, worktrees |
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
| `server/schedule/` | Cron-based scheduled agents |
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
| Module | Responsibility |
| ------------------------------- | ----------------------------------------------------------------------------- |
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
| `server/schedule/` | Cron-based scheduled agents |
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
### `packages/protocol` — Wire schemas and shared protocol types
@@ -115,9 +115,11 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
- `paseo terminal ls/create/capture/send-keys/kill`
- `paseo loop run/ls/inspect/logs/stop`
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
- `paseo heartbeat create/update/delete`
- `paseo workspace create/ls/archive`
- `paseo permit allow/deny/ls`
- `paseo provider ls/models`
- `paseo worktree create/ls/archive`
- hidden legacy `paseo worktree create/ls/archive` compatibility alias
- `paseo speech …`
Communicates with the daemon via the same WebSocket protocol as the app.

View File

@@ -82,7 +82,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for `create_agent` subagent relationships — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
@@ -289,8 +289,8 @@ One file per schedule. ID is 8 hex characters.
### Nested: ScheduleCadence (discriminated union on `type`)
- `{ type: "every", everyMs: number }` — interval in milliseconds
- `{ type: "cron", expression: string, timezone?: string }` — cron expression; absent `timezone` means UTC, present `timezone` is an IANA time zone used for local wall-clock recurrence
- `{ type: "cron", expression: string, timezone?: string }` — canonical cadence for new writes; absent `timezone` means UTC
- `{ type: "every", everyMs: number }` — legacy rolling interval, still readable and executable during the compatibility window
### Nested: ScheduleTarget (discriminated union on `type`)

View File

@@ -374,6 +374,8 @@ 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.
```bash
npm run cli -- ls -a -g # List all agents globally
npm run cli -- ls -a -g --json # Same, as JSON

View File

@@ -4,7 +4,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI and app shortcuts always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
@@ -16,7 +16,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific.
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
@@ -29,7 +29,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline.
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline.
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.

View File

@@ -73,7 +73,7 @@ Anyone who builds software:
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- Voice mode: dictate prompts or talk through problems hands-free
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees, workspace renaming)
- MCP server exposes the daemon to other agents (workspaces, create/detach agent, schedules, heartbeats, terminals, workspace renaming)
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
- Frequent releases (multiple per week)
- Community contributions across packaging, providers, and bug fixes

View File

@@ -13,7 +13,7 @@ import { Text, View } from "react-native";
import { Brain, Folder, GitBranch } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { ComboboxItem } from "@/components/ui/combobox";
@@ -71,6 +71,15 @@ function parseMaxRuns(raw: string): number | null {
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function requireCronCadence(
cadence: Extract<ScheduleCadence, { type: "cron" }> | undefined,
): Extract<ScheduleCadence, { type: "cron" }> {
if (!cadence) {
throw new Error("Choose a cron cadence before creating this schedule");
}
return cadence;
}
function resolveCreateServerId(input: {
mode: "create" | "edit";
serverId: string | null | undefined;
@@ -306,18 +315,15 @@ function OpenScheduleFormSheet({
]);
const submitAgentTarget = useCallback(async (): Promise<boolean> => {
if (!schedule) {
if (!schedule || !state.submitCadence) {
return false;
}
await updateSchedule({
id: schedule.id,
name: state.name.trim() || null,
prompt: state.prompt.trim(),
cadence: state.submitCadence,
maxRuns: parseMaxRuns(state.maxRuns),
});
return true;
}, [schedule, state.maxRuns, state.name, state.prompt, state.submitCadence, updateSchedule]);
}, [schedule, state.submitCadence, updateSchedule]);
const submitNewAgent = useCallback(async (): Promise<boolean> => {
const provider = state.selectedProvider;
@@ -333,7 +339,7 @@ function OpenScheduleFormSheet({
id: schedule.id,
name: state.name.trim() || null,
prompt: state.prompt.trim(),
cadence: state.submitCadence,
...(state.submitCadence ? { cadence: state.submitCadence } : {}),
newAgentConfig: {
provider,
model: state.selectedModel || null,
@@ -353,7 +359,7 @@ function OpenScheduleFormSheet({
await createSchedule({
prompt: state.prompt.trim(),
name: state.name.trim() || undefined,
cadence: state.submitCadence,
cadence: requireCronCadence(state.submitCadence),
target: {
type: "new-agent",
config: {
@@ -394,10 +400,12 @@ function OpenScheduleFormSheet({
void handleSubmit();
}, [handleSubmit]);
const header = useMemo<SheetHeader>(
() => ({ title: mode === "edit" ? "Edit schedule" : "New schedule" }),
[mode],
);
const header = useMemo<SheetHeader>(() => {
if (mode !== "edit") {
return { title: "New schedule" };
}
return { title: schedule?.target.type === "agent" ? "Edit heartbeat" : "Edit schedule" };
}, [mode, schedule?.target.type]);
const footer = useMemo(
() => (
@@ -466,6 +474,21 @@ function ScheduleFormFields({
cadenceError,
mutationServerId,
}: ScheduleFormFieldsProps): ReactElement {
if (state.targetKind === "agent") {
return (
<>
<ScheduleAgentTargetField label={agentTargetLabel} size={controlSize} />
<CadenceEditor
value={state.cadence}
onChange={model.setCadence}
error={cadenceError ?? undefined}
size={controlSize}
/>
{state.submitError ? <Text style={styles.submitError}>{state.submitError}</Text> : null}
</>
);
}
return (
<>
<Field label="Name">
@@ -502,7 +525,7 @@ function ScheduleFormFields({
model={model}
state={state}
providerSnapshot={providerSnapshot}
agentTargetLabel={agentTargetLabel}
agentTargetLabel={null}
controlSize={controlSize}
mutationServerId={mutationServerId}
/>

View File

@@ -16,7 +16,12 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
import {
formatCadence,
formatNextRun,
resolveScheduleTitle,
scheduleProductName,
} from "@/utils/schedule-format";
import { formatTimeAgo } from "@/utils/time";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
@@ -153,9 +158,10 @@ export function ScheduleRow({
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const title = resolveScheduleTitle(schedule);
const productName = scheduleProductName(schedule);
const badge = stateBadge(state);
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
const canRun = state === "active" || state === "paused";
const canRun = schedule.target.type === "new-agent" && (state === "active" || state === "paused");
const rowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
@@ -178,7 +184,7 @@ export function ScheduleRow({
style={rowStyle}
onPress={onEdit}
accessibilityRole="button"
accessibilityLabel={`Edit schedule ${title}`}
accessibilityLabel={`Edit ${productName.toLowerCase()} ${title}`}
testID={`schedule-row-${schedule.id}`}
>
<View style={styles.main}>
@@ -222,6 +228,66 @@ const resumeLeading = <ThemedPlay size={MENU_ICON_SIZE} uniProps={mutedColorMapp
const runLeading = <ThemedRotateCw size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
const deleteLeading = <ThemedTrash2 size={MENU_ICON_SIZE} uniProps={destructiveColorMapping} />;
function ScheduleExecutionMenuItems({
schedule,
canRun,
pending,
onPause,
onResume,
onRunNow,
}: Pick<ScheduleRowProps, "schedule" | "pending" | "onPause" | "onResume" | "onRunNow"> & {
canRun: boolean;
}): ReactElement | null {
if (schedule.target.type === "agent") {
return null;
}
let cadenceAction: ReactElement;
if (schedule.status === "paused") {
cadenceAction = (
<DropdownMenuItem
leading={resumeLeading}
disabled={!canRun}
status={pending?.resume ? "pending" : "idle"}
pendingLabel="Resuming..."
onSelect={onResume}
testID={`schedule-menu-resume-${schedule.id}`}
>
Resume schedule
</DropdownMenuItem>
);
} else {
cadenceAction = (
<DropdownMenuItem
leading={pauseLeading}
disabled={schedule.status === "completed" || !canRun}
status={pending?.pause ? "pending" : "idle"}
pendingLabel="Pausing..."
onSelect={onPause}
testID={`schedule-menu-pause-${schedule.id}`}
>
Pause schedule
</DropdownMenuItem>
);
}
return (
<>
{cadenceAction}
<DropdownMenuItem
leading={runLeading}
disabled={!canRun}
status={pending?.runNow ? "pending" : "idle"}
pendingLabel="Starting..."
onSelect={onRunNow}
testID={`schedule-menu-run-${schedule.id}`}
>
Run now
</DropdownMenuItem>
</>
);
}
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElement {
return (
<ThemedKebab
@@ -246,13 +312,15 @@ function ScheduleKebabMenu({
> & {
canRun: boolean;
}): ReactElement {
const productName = scheduleProductName(schedule);
const productNameLower = productName.toLowerCase();
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={kebabTriggerStyle}
accessibilityRole={isNative ? "button" : undefined}
accessibilityLabel="Schedule actions"
accessibilityLabel={`${productName} actions`}
testID={`schedule-kebab-${schedule.id}`}
>
{renderKebabTriggerIcon}
@@ -263,41 +331,16 @@ function ScheduleKebabMenu({
onSelect={onEdit}
testID={`schedule-menu-edit-${schedule.id}`}
>
Edit schedule
</DropdownMenuItem>
{schedule.status === "paused" ? (
<DropdownMenuItem
leading={resumeLeading}
disabled={!canRun}
status={pending?.resume ? "pending" : "idle"}
pendingLabel="Resuming..."
onSelect={onResume}
testID={`schedule-menu-resume-${schedule.id}`}
>
Resume schedule
</DropdownMenuItem>
) : (
<DropdownMenuItem
leading={pauseLeading}
disabled={schedule.status === "completed" || !canRun}
status={pending?.pause ? "pending" : "idle"}
pendingLabel="Pausing..."
onSelect={onPause}
testID={`schedule-menu-pause-${schedule.id}`}
>
Pause schedule
</DropdownMenuItem>
)}
<DropdownMenuItem
leading={runLeading}
disabled={!canRun}
status={pending?.runNow ? "pending" : "idle"}
pendingLabel="Starting..."
onSelect={onRunNow}
testID={`schedule-menu-run-${schedule.id}`}
>
Run now
Edit {productNameLower}
</DropdownMenuItem>
<ScheduleExecutionMenuItems
schedule={schedule}
canRun={canRun}
pending={pending}
onPause={onPause}
onResume={onResume}
onRunNow={onRunNow}
/>
<DropdownMenuSeparator />
<DropdownMenuItem
leading={deleteLeading}
@@ -307,7 +350,7 @@ function ScheduleKebabMenu({
onSelect={onDelete}
testID={`schedule-menu-delete-${schedule.id}`}
>
Delete schedule
Delete {productNameLower}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -7,7 +7,7 @@ import type { AggregatedSchedule } from "@/hooks/use-schedules";
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { resolveScheduleTitle } from "@/utils/schedule-format";
import { resolveScheduleTitle, scheduleProductName } from "@/utils/schedule-format";
/** A schedule plus the client-derived fields the row renders. */
export interface ScheduleRowView {
@@ -113,8 +113,9 @@ function SchedulesTableRow({
const handleDelete = useCallback(() => {
void (async () => {
const productName = scheduleProductName(schedule);
const confirmed = await confirmDialog({
title: "Delete schedule",
title: `Delete ${productName.toLowerCase()}`,
message: `Delete "${resolveScheduleTitle(schedule)}"? This cannot be undone.`,
confirmLabel: "Delete",
destructive: true,

View File

@@ -113,6 +113,26 @@ function scheduleOnHost(input: {
};
}
function heartbeatOnHost(cadence: ScheduleSummary["cadence"]): TestSchedule {
return {
id: "heartbeat-host-a",
serverId: "host-a",
serverName: "Host A",
name: "Babysit",
prompt: "Check status",
cadence,
target: { type: "agent", agentId: "agent-1" },
status: "active",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
nextRunAt: "2026-07-02T00:00:00.000Z",
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
};
}
function providerSnapshot(models: AgentModelDefinition[]): { entries: ProviderSnapshotEntry[] } {
return {
entries: [
@@ -389,7 +409,7 @@ describe("schedule form model", () => {
});
});
it("normalizes interval cadences to cron cadences when opening the form", () => {
it("displays a representable legacy interval without submitting a cadence change", () => {
const form = open({
mode: "edit",
schedule: scheduleOnHost({
@@ -412,9 +432,12 @@ describe("schedule form model", () => {
expression: "* * * * *",
timezone: "Europe/Madrid",
});
form.setName("Renamed without touching cadence");
expect(form.getState().submitCadence).toBeUndefined();
});
it("preserves an edited schedule's interval cadence until the cadence changes", () => {
it("does not rewrite an unrepresentable legacy interval until cadence changes", () => {
const originalCadence = { type: "every" as const, everyMs: 90 * 60_000 };
const form = open({
mode: "edit",
@@ -441,7 +464,7 @@ describe("schedule form model", () => {
form.setName("Renamed without touching cadence");
expect(form.getState().submitCadence).toEqual(originalCadence);
expect(form.getState().submitCadence).toBeUndefined();
form.setCadence({
type: "cron",
@@ -456,6 +479,28 @@ describe("schedule form model", () => {
});
});
it("requires a cron choice before updating a legacy heartbeat", () => {
const form = open({
mode: "edit",
schedule: heartbeatOnHost({ type: "every", everyMs: 90 * 60_000 }),
defaults: {
serverId: null,
projectTargets: PROJECT_TARGETS,
preferences: {},
timezone: "Europe/Madrid",
},
});
expect(form.getState()).toMatchObject({ targetKind: "agent", canSubmit: false });
form.setCadence({ type: "cron", expression: "0 9 * * *", timezone: "Europe/Madrid" });
expect(form.getState()).toMatchObject({
submitCadence: { type: "cron", expression: "0 9 * * *", timezone: "Europe/Madrid" },
canSubmit: true,
});
});
it("clears provider selection while resolving a different project", () => {
const form = open({
mode: "create",

View File

@@ -77,6 +77,7 @@ export interface ScheduleFormProjectOption {
}
export type ScheduleFormTargetKind = "agent" | "new-agent";
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
type ProviderResolutionStatus = "idle" | "pending" | "complete";
export interface ScheduleFormState {
@@ -86,7 +87,7 @@ export interface ScheduleFormState {
prompt: string;
maxRuns: string;
cadence: ScheduleCadence;
submitCadence: ScheduleCadence;
submitCadence: CronCadence | undefined;
hosts: ScheduleFormHost[];
projectOptions: ScheduleFormProjectOption[];
selectedServerId: string | null;
@@ -436,13 +437,10 @@ function formatInitialMaxRuns(schedule: ScheduleFormSnapshot["schedule"]): strin
}
function resolveInitialSubmitCadence(
snapshot: ScheduleFormSnapshot,
initialCadence: ScheduleCadence,
): ScheduleCadence {
if (snapshot.mode === "edit" && snapshot.schedule) {
return snapshot.schedule.cadence;
}
return initialCadence;
schedule: ScheduleFormSnapshot["schedule"],
initialCadence: CronCadence,
): CronCadence | undefined {
return schedule ? undefined : initialCadence;
}
function resolveInitialIsolation(input: {
@@ -547,12 +545,12 @@ function resolveDisclosure(state: ScheduleFormState): ScheduleDisclosureState {
}
function resolveCanSubmit(state: ScheduleFormState): boolean {
if (state.targetKind === "agent") {
return state.submitCadence !== undefined;
}
if (state.prompt.trim().length === 0) {
return false;
}
if (state.targetKind === "agent") {
return true;
}
const hasWorkingDir = state.workingDir.trim().length > 0;
const hasMatchedProject = state.selectedProjectOptionId.trim().length > 0;
if (state.mode === "create" && !hasMatchedProject) {
@@ -657,7 +655,7 @@ function buildInitialState(snapshot: ScheduleFormSnapshot): ScheduleFormState {
prompt: snapshot.schedule?.prompt ?? "",
maxRuns: formatInitialMaxRuns(snapshot.schedule),
cadence: initialCadence,
submitCadence: resolveInitialSubmitCadence(snapshot, initialCadence),
submitCadence: resolveInitialSubmitCadence(snapshot.schedule, initialCadence),
hosts: [...snapshot.hosts],
projectOptions: buildProjectOptions(snapshot.defaults.projectTargets, selectedServerId),
selectedServerId,

View File

@@ -6,6 +6,7 @@ import {
formatCadence,
formatNextRun,
isNewAgentSchedule,
scheduleProductName,
partsToEveryMs,
resolveScheduleTitle,
validateCron,
@@ -54,6 +55,11 @@ describe("schedule title helpers", () => {
expect(isNewAgentSchedule(createSchedule({ targetType: "agent" }))).toBe(false);
});
it("labels engine records by product meaning", () => {
expect(scheduleProductName(createSchedule({ targetType: "new-agent" }))).toBe("Schedule");
expect(scheduleProductName(createSchedule({ targetType: "agent" }))).toBe("Heartbeat");
});
it("resolves display titles by name, config title, prompt, then fallback", () => {
expect(
resolveScheduleTitle(createSchedule({ name: "Morning run", title: "Config title" })),

View File

@@ -28,6 +28,10 @@ export function isNewAgentSchedule(schedule: ScheduleSummary): boolean {
return schedule.target.type === "new-agent";
}
export function scheduleProductName(schedule: ScheduleSummary): "Heartbeat" | "Schedule" {
return schedule.target.type === "agent" ? "Heartbeat" : "Schedule";
}
export function resolveScheduleTitle(schedule: ScheduleSummary): string {
const name = schedule.name?.trim();
if (name) {
@@ -43,7 +47,7 @@ export function resolveScheduleTitle(schedule: ScheduleSummary): string {
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0);
return firstPromptLine || "Untitled schedule";
return firstPromptLine || `Untitled ${scheduleProductName(schedule).toLowerCase()}`;
}
function pluralize(value: number, noun: string): string {

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { createCli } from "./cli.js";
describe("canonical CLI surface", () => {
it("shows workspace and heartbeat commands while hiding worktree compatibility", () => {
const cli = createCli();
const help = cli.helpInformation();
expect(help).toContain("workspace");
expect(help).toContain("heartbeat");
expect(help).not.toContain("worktree");
});
it("hides legacy run worktree syntax", () => {
const run = createCli().commands.find((command) => command.name() === "run");
expect(run?.helpInformation()).toContain("--isolation <local|worktree>");
expect(run?.helpInformation()).not.toContain("--worktree <name>");
});
it("uses background for execution and reserves detach for ownership", () => {
const run = createCli().commands.find((command) => command.name() === "run");
expect(run?.helpInformation()).toContain("--background");
expect(run?.helpInformation()).not.toContain("--detach");
});
});

View File

@@ -9,6 +9,8 @@ import { createScheduleCommand } from "./commands/schedule/index.js";
import { createSpeechCommand } from "./commands/speech/index.js";
import { createTerminalCommand } from "./commands/terminal/index.js";
import { createWorktreeCommand } from "./commands/worktree/index.js";
import { createWorkspaceCommand } from "./commands/workspace/index.js";
import { createHeartbeatCommand } from "./commands/heartbeat/index.js";
import { createHubCommand } from "./commands/hub/index.js";
import { createHooksCommand } from "./commands/hooks.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
@@ -174,6 +176,7 @@ export function createCli(): Command {
// Schedule commands
program.addCommand(createScheduleCommand());
program.addCommand(createHeartbeatCommand());
// Permission commands
program.addCommand(createPermitCommand());
@@ -184,8 +187,11 @@ export function createCli(): Command {
// Speech model commands
program.addCommand(createSpeechCommand());
// Worktree commands
program.addCommand(createWorktreeCommand());
// Workspace commands
program.addCommand(createWorkspaceCommand());
// COMPAT(worktreeCli): legacy command alias added before workspace was the product unit.
// Added in v0.2.0; remove after 2027-01-17.
program.addCommand(createWorktreeCommand(), { hidden: true });
return program;
}

View File

@@ -0,0 +1,58 @@
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
import type {
CommandError,
CommandOptions,
OutputSchema,
SingleResult,
} from "../../output/index.js";
interface AgentDetachResult {
agentId: string;
status: "detached";
}
const detachSchema: OutputSchema<AgentDetachResult> = {
idField: "agentId",
columns: [
{ header: "AGENT ID", field: "agentId" },
{ header: "STATUS", field: "status" },
],
};
export async function runDetachCommand(
agentIdArg: string,
options: CommandOptions,
_command: Command,
): Promise<SingleResult<AgentDetachResult>> {
const host = getDaemonHost({ host: options.host });
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
} satisfies CommandError;
}
try {
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
const agentId = resolveAgentId(
agentIdArg,
payload.entries.map((entry) => entry.agent),
);
if (!agentId) {
throw {
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdArg}`,
} satisfies CommandError;
}
await client.detachAgent(agentId);
return { type: "single", data: { agentId, status: "detached" }, schema: detachSchema };
} finally {
await client.close().catch(() => undefined);
}
}

View File

@@ -13,6 +13,7 @@ import { addAttachOptions, runAttachCommand } from "./attach.js";
import { addReloadOptions, runReloadCommand } from "./reload.js";
import { addImportOptions, runImportCommand } from "./import.js";
import { runUpdateCommand } from "./update.js";
import { runDetachCommand } from "./detach.js";
import { withOutput } from "../../output/index.js";
import {
addDaemonHostOption,
@@ -76,6 +77,13 @@ export function createAgentCommand(): Command {
withOutput(runReloadCommand),
);
addJsonAndDaemonHostOptions(
agent
.command("detach")
.description("Make a subagent independent without stopping or moving it")
.argument("<id>", "Agent ID, prefix, or name"),
).action(withOutput(runDetachCommand));
addJsonAndDaemonHostOptions(
agent
.command("update")

View File

@@ -1,5 +1,52 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { runRunCommand, type AgentRunOptions } from "./run";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
resolveExistingRunWorkspace,
resolveRunCallerAgentId,
runRunCommand,
type AgentRunOptions,
} from "./run";
describe("managed agent caller context", () => {
it("propagates a trimmed PASEO_AGENT_ID", () => {
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent");
});
it("omits blank caller ids", () => {
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " " })).toBeUndefined();
});
});
describe("existing run workspace resolution", () => {
it("queries the daemon for an exact workspace id and uses its directory", async () => {
const fetchWorkspaces = vi.fn().mockResolvedValue({
entries: [{ id: "workspace-2", workspaceDirectory: "/workspace/two" }],
pageInfo: { nextCursor: null },
});
await expect(resolveExistingRunWorkspace({ fetchWorkspaces }, "workspace-2")).resolves.toEqual({
id: "workspace-2",
cwd: "/workspace/two",
});
expect(fetchWorkspaces).toHaveBeenCalledWith({
filter: { query: "workspace-2" },
page: { limit: 200 },
});
});
it("rejects a workspace id absent from daemon state", async () => {
const fetchWorkspaces = vi.fn().mockResolvedValue({
entries: [],
pageInfo: { nextCursor: null },
});
await expect(resolveExistingRunWorkspace({ fetchWorkspaces }, "missing")).rejects.toMatchObject(
{
code: "WORKSPACE_NOT_FOUND",
message: "Workspace not found: missing",
},
);
});
});
// validateRunOptions runs before the CLI ever connects to a daemon, so these
// invalid combinations reject without one running.
@@ -25,27 +72,23 @@ describe("runRunCommand option validation", () => {
});
}
it("rejects --worktree combined with --workspace", async () => {
it("rejects --isolation combined with --workspace", async () => {
await expectInvalidOptions(
{ worktree: "feat", workspace: "ws-1" },
/--worktree and --workspace cannot be combined/,
{ isolation: "worktree", workspace: "ws-1" },
/--isolation and --workspace cannot be combined/,
);
});
it("rejects --worktree combined with an ambient PASEO_WORKSPACE_ID", async () => {
process.env.PASEO_WORKSPACE_ID = "ws-ambient";
await expectInvalidOptions(
{ worktree: "feat" },
/--worktree cannot be combined with an ambient PASEO_WORKSPACE_ID/,
);
});
it("allows a bare --worktree through validation when no workspace is selected", async () => {
// A bare --worktree with no --workspace and no ambient PASEO_WORKSPACE_ID
it("allows explicit worktree isolation through validation", async () => {
// Explicit isolation 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", { worktree: "feat", provider: undefined }, {} as never),
runRunCommand("do something", { isolation: "worktree", provider: undefined }, {} as never),
).rejects.not.toMatchObject({ code: "INVALID_OPTIONS" });
});
it("rejects unknown workspace isolation", async () => {
await expectInvalidOptions({ isolation: "container" }, /Unsupported workspace isolation/);
});
});

View File

@@ -18,55 +18,61 @@ import { resolveProviderAndModel } from "../../utils/provider-model.js";
export { resolveProviderAndModel } from "../../utils/provider-model.js";
export function addRunOptions(cmd: Command): Command {
return cmd
.description("Create and start an agent with a task")
.argument("<prompt>", "The task/prompt for the agent")
.option("-d, --detach", "Run in background (detached)")
.option("--title <title>", "Assign a title to the agent")
.addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp())
.option(
"--provider <provider>",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
)
.option(
"--model <model>",
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
)
.option("--thinking <id>", "Thinking option ID to use for this run")
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
.option("--worktree <name>", "Create agent in a new git worktree")
.option("--base <branch>", "Base branch for worktree (default: current branch)")
.option(
"--workspace <id>",
"Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)",
)
.option(
"--image <path>",
"Attach image(s) to the initial prompt (can be used multiple times)",
collectMultiple,
[],
)
.option("--cwd <path>", "Working directory (default: current)")
.option(
"--env <key=value>",
"Set environment variable(s) for the agent process (can be used multiple times)",
collectMultiple,
[],
)
.option(
"--label <key=value>",
"Add label(s) to the agent (can be used multiple times)",
collectMultiple,
[],
)
.option(
"--wait-timeout <duration>",
"Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit",
)
.option(
"--output-schema <schema>",
"Output JSON matching the provided schema file path or inline JSON schema",
);
return (
cmd
.description("Create and start an agent with a task")
.argument("<prompt>", "The task/prompt for the agent")
.option("-d, --background", "Run in background")
// COMPAT(detachRunFlag): --detach used to mean background execution, not
// ownership transfer. Added in v0.2.0; remove after 2027-01-17.
.addOption(new Option("--detach", "Legacy alias for --background").hideHelp())
.option("--title <title>", "Assign a title to the agent")
.addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp())
.option(
"--provider <provider>",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
)
.option(
"--model <model>",
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
)
.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")
.addOption(new Option("--worktree <name>", "Legacy workspace isolation alias").hideHelp())
.option("--base <branch>", "Base branch for an isolated workspace")
.option(
"--workspace <id>",
"Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)",
)
.option(
"--image <path>",
"Attach image(s) to the initial prompt (can be used multiple times)",
collectMultiple,
[],
)
.option("--cwd <path>", "Working directory (default: current)")
.option(
"--env <key=value>",
"Set environment variable(s) for the agent process (can be used multiple times)",
collectMultiple,
[],
)
.option(
"--label <key=value>",
"Add label(s) to the agent (can be used multiple times)",
collectMultiple,
[],
)
.option(
"--wait-timeout <duration>",
"Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit",
)
.option(
"--output-schema <schema>",
"Output JSON matching the provided schema file path or inline JSON schema",
)
);
}
/** Result type for agent run command */
@@ -91,6 +97,7 @@ export const agentRunSchema: OutputSchema<AgentRunResult> = {
};
export interface AgentRunOptions extends CommandOptions {
background?: boolean;
detach?: boolean;
title?: string;
name?: string;
@@ -98,6 +105,7 @@ export interface AgentRunOptions extends CommandOptions {
model?: string;
thinking?: string;
mode?: string;
isolation?: string;
worktree?: string;
base?: string;
workspace?: string;
@@ -270,40 +278,54 @@ function validateRunOptions(prompt: string, options: AgentRunOptions, outputSche
} satisfies CommandError;
}
if (options.base && !options.worktree) {
const createsIsolatedWorkspace = options.isolation === "worktree" || Boolean(options.worktree);
if (options.isolation && options.isolation !== "local" && options.isolation !== "worktree") {
throw {
code: "INVALID_OPTIONS",
message: "--base can only be used with --worktree",
details: "Usage: paseo agent run --worktree <name> --base <branch> <prompt>",
message: `Unsupported workspace isolation: ${options.isolation}`,
details: "Use --isolation local or --isolation worktree",
} satisfies CommandError;
}
if (options.base && !createsIsolatedWorkspace) {
throw {
code: "INVALID_OPTIONS",
message: "--base can only be used with --isolation worktree",
details: "Usage: paseo agent run --isolation worktree --base <branch> <prompt>",
} satisfies CommandError;
}
if (options.isolation && 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",
} satisfies CommandError;
}
// COMPAT(worktreeRunFlag): --worktree implies a new worktree-isolated workspace.
// Added in v0.2.0; remove after 2027-01-17.
if (options.worktree && options.workspace) {
throw {
code: "INVALID_OPTIONS",
message: "--worktree and --workspace cannot be combined",
details: "--workspace runs in an existing workspace; --worktree mints a new one",
details: "Use --isolation worktree instead of the legacy --worktree flag",
} satisfies CommandError;
}
if (options.worktree && !options.workspace && process.env.PASEO_WORKSPACE_ID) {
if (outputSchema && runsInBackground(options)) {
throw {
code: "INVALID_OPTIONS",
message: "--worktree cannot be combined with an ambient PASEO_WORKSPACE_ID",
details:
"PASEO_WORKSPACE_ID selects an existing workspace; --worktree mints a new one. Unset PASEO_WORKSPACE_ID to use --worktree.",
} satisfies CommandError;
}
if (outputSchema && options.detach) {
throw {
code: "INVALID_OPTIONS",
message: "--output-schema cannot be used with --detach",
message: "--output-schema cannot be used with --background",
details: "Structured output requires waiting for the agent to finish",
} satisfies CommandError;
}
}
function runsInBackground(options: Pick<AgentRunOptions, "background" | "detach">): boolean {
return Boolean(options.background || options.detach);
}
function parseWaitTimeoutOption(waitTimeout: string | undefined): number {
if (!waitTimeout) return 0;
try {
@@ -409,45 +431,79 @@ async function connectToDaemonOrThrow(
// runs in. The CLI resolves one before creating any agent, so no run leans on
// createAgent's legacy cwd->workspace fallback.
interface RunWorkspace {
id: string;
id?: string;
cwd: string;
}
export interface RunWorkspaceLookupClient {
fetchWorkspaces(options: { filter: { query: string }; page: { limit: number } }): Promise<{
entries: Array<{ id: string; workspaceDirectory: string }>;
pageInfo: { nextCursor: string | null };
}>;
}
export async function resolveExistingRunWorkspace(
client: RunWorkspaceLookupClient,
workspaceId: string,
): Promise<RunWorkspace> {
const result = await client.fetchWorkspaces({
filter: { query: workspaceId },
page: { limit: 200 },
});
const workspace = result.entries.find((entry) => entry.id === workspaceId);
if (workspace) {
return { id: workspace.id, cwd: workspace.workspaceDirectory };
}
throw {
code: "WORKSPACE_NOT_FOUND",
message: `Workspace not found: ${workspaceId}`,
} satisfies CommandError;
}
// Workspace policy for `paseo run`. Precedence:
// 1. --workspace <id> -> run in that existing workspace
// 2. $PASEO_WORKSPACE_ID -> exported by workspace terminals
// 3. --worktree <name> -> mint a new worktree-backed workspace
// 4. bare run -> mint a new local-backed workspace for cwd
// --worktree is rejected alongside both --workspace and an ambient
// $PASEO_WORKSPACE_ID (validateRunOptions), so worktree resolution here never
// races an existing-workspace selection.
// 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
// 5. bare run -> mint a new local-backed workspace for cwd
async function resolveRunWorkspace(
client: ConnectedDaemonClient,
options: AgentRunOptions,
cwd: string,
): Promise<RunWorkspace> {
// An explicit --worktree mints its own workspace; --workspace and an ambient
// PASEO_WORKSPACE_ID are both rejected alongside --worktree upstream.
const explicit = options.worktree
? undefined
: options.workspace?.trim() || process.env.PASEO_WORKSPACE_ID?.trim();
const requestedIsolation = options.isolation ?? (options.worktree ? "worktree" : undefined);
const explicit = requestedIsolation ? undefined : options.workspace?.trim();
if (explicit) {
console.error(`Using workspace ${explicit}`);
return { id: explicit, cwd };
return resolveExistingRunWorkspace(client, explicit);
}
if (!requestedIsolation && resolveRunCallerAgentId()) {
return { cwd };
}
const ambientWorkspaceId = requestedIsolation
? undefined
: process.env.PASEO_WORKSPACE_ID?.trim();
if (ambientWorkspaceId) {
console.error(`Using workspace ${ambientWorkspaceId}`);
return resolveExistingRunWorkspace(client, ambientWorkspaceId);
}
// TODO: thread the run `prompt` as firstAgentContext so workspace-level
// title/branch generation picks up the task description (U8/U6 deferred).
const result = options.worktree
? await client.createWorkspace({
source: {
kind: "worktree",
cwd,
worktreeSlug: options.worktree,
baseBranch: options.base,
},
})
: await client.createWorkspace({ source: { kind: "directory", path: cwd } });
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 } });
if (!result.workspace) {
throw {
@@ -503,6 +559,7 @@ export async function runRunCommand(
const workspace = await resolveRunWorkspace(client, options, cwd);
const workspaceId = workspace.id;
const callerAgentId = resolveRunCallerAgentId();
const runCwd = workspace.cwd;
if (outputSchema) {
@@ -514,6 +571,7 @@ export async function runRunCommand(
provider: resolvedProviderModel.provider,
cwd: runCwd,
workspaceId,
callerAgentId,
title: resolvedTitle,
modeId: options.mode,
model: resolvedProviderModel.model,
@@ -584,6 +642,7 @@ export async function runRunCommand(
provider: resolvedProviderModel.provider,
cwd: runCwd,
workspaceId,
callerAgentId,
title: resolvedTitle,
modeId: options.mode,
model: resolvedProviderModel.model,
@@ -594,8 +653,8 @@ export async function runRunCommand(
labels: Object.keys(labels).length > 0 ? labels : undefined,
});
// Default run behavior is foreground: wait for completion unless --detach is set.
if (!options.detach) {
// Default run behavior is foreground: wait for completion unless background execution is set.
if (!runsInBackground(options)) {
const state = await client.waitForFinish(agent.id, waitTimeoutMs);
await client.close();
@@ -631,3 +690,9 @@ export async function runRunCommand(
throw error;
}
}
export function resolveRunCallerAgentId(
env: { PASEO_AGENT_ID?: string } = process.env,
): string | undefined {
return env.PASEO_AGENT_ID?.trim() || undefined;
}

View File

@@ -0,0 +1,180 @@
import { Command } from "commander";
import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { parseDuration } from "../../utils/duration.js";
import {
connectScheduleClient,
toScheduleCommandError,
toScheduleRow,
type ScheduleRow,
} from "../schedule/shared.js";
import { scheduleSchema } from "../schedule/schema.js";
interface HeartbeatOptions extends CommandOptions {
cron?: string;
timezone?: string;
name?: string;
maxRuns?: string;
expiresIn?: string;
}
interface HeartbeatDeleteRow {
id: string;
status: "deleted";
}
const heartbeatDeleteSchema: OutputSchema<HeartbeatDeleteRow> = {
idField: "id",
columns: [
{ header: "ID", field: "id" },
{ header: "STATUS", field: "status" },
],
};
function requireCallerAgentId(): string {
const agentId = process.env.PASEO_AGENT_ID?.trim();
if (!agentId) {
throw new Error("Heartbeat commands must run inside a Paseo agent");
}
return agentId;
}
async function requireOwnedHeartbeat(
client: Awaited<ReturnType<typeof connectScheduleClient>>["client"],
id: string,
agentId: string,
): Promise<void> {
const payload = await client.scheduleInspect({ id });
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Heartbeat not found: ${id}`);
}
if (payload.schedule.target.type !== "agent" || payload.schedule.target.agentId !== agentId) {
throw new Error(`Heartbeat ${id} does not belong to agent ${agentId}`);
}
}
async function runCreateHeartbeat(
prompt: string,
options: HeartbeatOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const agentId = requireCallerAgentId();
const cron = options.cron?.trim();
if (!cron) {
throw new Error("--cron is required");
}
const { client } = await connectScheduleClient(options.host);
try {
const maxRuns = options.maxRuns ? Number.parseInt(options.maxRuns, 10) : undefined;
if (maxRuns !== undefined && (!Number.isSafeInteger(maxRuns) || maxRuns <= 0)) {
throw new Error("--max-runs must be a positive integer");
}
const payload = await client.scheduleCreate({
prompt: prompt.trim(),
cadence: {
type: "cron",
expression: cron,
...(options.timezone?.trim() ? { timezone: options.timezone.trim() } : {}),
},
target: { type: "agent", agentId },
...(options.name?.trim() ? { name: options.name.trim() } : {}),
...(maxRuns ? { maxRuns } : {}),
...(options.expiresIn
? { expiresAt: new Date(Date.now() + parseDuration(options.expiresIn)).toISOString() }
: {}),
});
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? "Heartbeat creation failed");
}
return { type: "single", data: toScheduleRow(payload.schedule), schema: scheduleSchema };
} catch (error) {
throw toScheduleCommandError("HEARTBEAT_CREATE_FAILED", "create heartbeat", error);
} finally {
await client.close().catch(() => undefined);
}
}
async function runUpdateHeartbeat(
id: string,
options: HeartbeatOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const agentId = requireCallerAgentId();
const cron = options.cron?.trim();
if (!cron) {
throw new Error("--cron is required");
}
const { client } = await connectScheduleClient(options.host);
try {
await requireOwnedHeartbeat(client, id, agentId);
const payload = await client.scheduleUpdate({
id,
cadence: {
type: "cron",
expression: cron,
...(options.timezone?.trim() ? { timezone: options.timezone.trim() } : {}),
},
});
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Heartbeat update failed: ${id}`);
}
return { type: "single", data: toScheduleRow(payload.schedule), schema: scheduleSchema };
} catch (error) {
throw toScheduleCommandError("HEARTBEAT_UPDATE_FAILED", "update heartbeat", error);
} finally {
await client.close().catch(() => undefined);
}
}
async function runDeleteHeartbeat(
id: string,
options: HeartbeatOptions,
_command: Command,
): Promise<SingleResult<HeartbeatDeleteRow>> {
const agentId = requireCallerAgentId();
const { client } = await connectScheduleClient(options.host);
try {
await requireOwnedHeartbeat(client, id, agentId);
const payload = await client.scheduleDelete({ id });
if (payload.error) {
throw new Error(payload.error);
}
return {
type: "single",
data: { id: payload.scheduleId, status: "deleted" },
schema: heartbeatDeleteSchema,
};
} catch (error) {
throw toScheduleCommandError("HEARTBEAT_DELETE_FAILED", "delete heartbeat", error);
} finally {
await client.close().catch(() => undefined);
}
}
export function createHeartbeatCommand(): Command {
const heartbeat = new Command("heartbeat").description("Manage this agent's heartbeats");
addJsonAndDaemonHostOptions(
heartbeat
.command("create")
.description("Create a recurring prompt for this agent")
.argument("<prompt>", "Prompt to send")
.requiredOption("--cron <expr>", "Five-field cron cadence")
.option("--timezone <iana>", "IANA time zone")
.option("--name <name>", "Heartbeat name")
.option("--max-runs <n>", "Maximum number of runs")
.option("--expires-in <duration>", "Time to live"),
).action(withOutput(runCreateHeartbeat));
addJsonAndDaemonHostOptions(
heartbeat
.command("update")
.description("Change a heartbeat cron cadence")
.argument("<id>", "Heartbeat ID")
.requiredOption("--cron <expr>", "Five-field cron cadence")
.option("--timezone <iana>", "IANA time zone"),
).action(withOutput(runUpdateHeartbeat));
addJsonAndDaemonHostOptions(
heartbeat.command("delete").description("Delete a heartbeat").argument("<id>", "Heartbeat ID"),
).action(withOutput(runDeleteHeartbeat));
return heartbeat;
}

View File

@@ -1,4 +1,4 @@
import { Command } from "commander";
import { Command, Option } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { runCreateCommand } from "./create.js";
@@ -19,11 +19,11 @@ export function createScheduleCommand(): Command {
.command("create")
.description("Create a schedule")
.argument("<prompt>", "Prompt to run on the schedule")
.option("--every <duration>", "Fixed interval cadence (for example: 5m, 1h)")
.option("--every <duration>", "Cron-compatible cadence preset (for example: 5m, 1h)")
.option("--cron <expr>", "Cron cadence expression")
.option("--timezone <iana>", "IANA time zone for cron cadence (default: UTC)")
.option("--name <name>", "Optional schedule name")
.option("--target <self|new-agent|agent-id>", "Run target")
.addOption(new Option("--target <target>", "Legacy schedule target").hideHelp())
.option(
"--provider <provider>",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
@@ -33,8 +33,7 @@ export function createScheduleCommand(): Command {
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
)
.option("--cwd <path>", "Working directory (default: current; required with --host)")
.option("--run-now", "Fire one immediate run on creation (only with --cron)")
.option("--no-run-now", "Wait the full interval before the first run (only with --every)")
.option("--run-now", "Fire one immediate run on creation")
.option("--max-runs <n>", "Maximum number of runs")
.option("--expires-in <duration>", "Time to live for the schedule"),
).action(withOutput(runCreateCommand));
@@ -81,7 +80,7 @@ export function createScheduleCommand(): Command {
.command("update")
.description("Update an existing schedule in place")
.argument("<id>", "Schedule ID")
.option("--every <duration>", "Switch to fixed interval cadence (for example: 5m, 1h)")
.option("--every <duration>", "Cron-compatible cadence preset (for example: 5m, 1h)")
.option("--cron <expr>", "Switch to cron cadence expression")
.option("--timezone <iana>", "IANA time zone for cron cadence (requires --cron)")
.option("--name <name>", "Rename the schedule (empty string clears the name)")

View File

@@ -22,6 +22,9 @@ export async function runInspectCommand(
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Schedule not found: ${id}`);
}
if (payload.schedule.target.type !== "new-agent") {
throw new Error(`Schedule not found: ${id}`);
}
const rows = createScheduleInspectRows(payload.schedule);
return {
type: "list",

View File

@@ -3,6 +3,7 @@ import type { ListResult } from "../../output/index.js";
import { scheduleLogSchema, toScheduleLogRow, type ScheduleLogRow } from "./schema.js";
import {
connectScheduleClient,
requireNewAgentSchedule,
toScheduleCommandError,
type ScheduleCommandOptions,
} from "./shared.js";
@@ -14,6 +15,7 @@ export async function runLogsCommand(
): Promise<ListResult<ScheduleLogRow>> {
const { client } = await connectScheduleClient(options.host);
try {
await requireNewAgentSchedule(client, id);
const payload = await client.scheduleLogs({ id });
if (payload.error) {
throw new Error(payload.error);

View File

@@ -21,7 +21,9 @@ export async function runLsCommand(
}
return {
type: "list",
data: payload.schedules.map(toScheduleRow),
data: payload.schedules
.filter((schedule) => schedule.target.type === "new-agent")
.map(toScheduleRow),
schema: scheduleSchema,
};
} catch (error) {

View File

@@ -3,6 +3,7 @@ import type { SingleResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
requireNewAgentSchedule,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
@@ -16,6 +17,7 @@ export async function runPauseCommand(
): Promise<SingleResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
try {
await requireNewAgentSchedule(client, id);
const payload = await client.schedulePause({ id });
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Failed to pause schedule: ${id}`);

View File

@@ -3,6 +3,7 @@ import type { SingleResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
requireNewAgentSchedule,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
@@ -16,6 +17,7 @@ export async function runResumeCommand(
): Promise<SingleResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
try {
await requireNewAgentSchedule(client, id);
const payload = await client.scheduleResume({ id });
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Failed to resume schedule: ${id}`);

View File

@@ -3,6 +3,7 @@ import type { SingleResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
requireNewAgentSchedule,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
@@ -16,6 +17,7 @@ export async function runRunOnceCommand(
): Promise<SingleResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
try {
await requireNewAgentSchedule(client, id);
const payload = await client.scheduleRunOnce({ id });
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Failed to run schedule once: ${id}`);

View File

@@ -1,6 +1,10 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { parseScheduleCreateInput, parseScheduleUpdateInput } from "./shared.js";
import {
compileEveryPresetToCron,
parseScheduleCreateInput,
parseScheduleUpdateInput,
} from "./shared.js";
const baseOptions = {
prompt: "do the thing",
@@ -68,13 +72,9 @@ describe("parseScheduleCreateInput cwd/host validation", () => {
});
describe("parseScheduleCreateInput first-run timing", () => {
test("--every with no run-now flag fires immediately on creation", () => {
test("--every compiles to cron and waits for the next slot", () => {
const input = parseScheduleCreateInput(baseOptions);
expect(input.runOnCreate).toBe(true);
});
test("--every with --no-run-now waits the interval", () => {
const input = parseScheduleCreateInput({ ...baseOptions, runNow: false });
expect(input.cadence).toEqual({ type: "cron", expression: "*/5 * * * *" });
expect(input.runOnCreate).toBe(false);
});
@@ -101,22 +101,9 @@ describe("parseScheduleCreateInput first-run timing", () => {
});
});
test("--every with --run-now is rejected as redundant", () => {
expect(() => parseScheduleCreateInput({ ...baseOptions, runNow: true })).toThrow(
expect.objectContaining({
code: "REDUNDANT_RUN_NOW",
message: expect.stringContaining("--run-now is redundant with --every"),
}),
);
});
test("--cron with --no-run-now is rejected as redundant", () => {
expect(() => parseScheduleCreateInput({ ...baseCron, runNow: false })).toThrow(
expect.objectContaining({
code: "REDUNDANT_NO_RUN_NOW",
message: expect.stringContaining("--no-run-now is redundant with --cron"),
}),
);
test("--run-now is orthogonal to a compiled preset", () => {
const input = parseScheduleCreateInput({ ...baseOptions, runNow: true });
expect(input.runOnCreate).toBe(true);
});
test("--timezone without --cron is rejected", () => {
@@ -159,10 +146,10 @@ describe("parseScheduleUpdateInput", () => {
);
});
test("parses --every cadence", () => {
test("compiles --every cadence to cron", () => {
expect(parseScheduleUpdateInput({ id: "abc", every: "5m" })).toEqual({
id: "abc",
cadence: { type: "every", everyMs: 5 * 60_000 },
cadence: { type: "cron", expression: "*/5 * * * *" },
});
});
@@ -271,3 +258,25 @@ describe("parseScheduleUpdateInput", () => {
).toThrow(expect.objectContaining({ code: "CONFLICTING_EXPIRES" }));
});
});
describe("compileEveryPresetToCron", () => {
test.each([
["1m", "*/1 * * * *"],
["15m", "*/15 * * * *"],
["1h", "0 * * * *"],
["6h", "0 */6 * * *"],
["1d", "0 0 * * *"],
])("compiles %s", (value, expected) => {
expect(compileEveryPresetToCron(value)).toBe(expected);
});
test.each(["30s", "7m", "5h", "2d"])("rejects rolling interval %s", (value) => {
expect(() => compileEveryPresetToCron(value)).toThrow(
expect.objectContaining({ code: "UNREPRESENTABLE_CADENCE" }),
);
});
test.each(["15minutes", "junk15m", "1h-nope"])("rejects malformed preset %s", (value) => {
expect(() => compileEveryPresetToCron(value)).toThrow("Invalid duration format");
});
});

View File

@@ -12,6 +12,7 @@ import type {
} from "./types.js";
import { parseDuration } from "../../utils/duration.js";
import { resolveProviderAndModel } from "../../utils/provider-model.js";
import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence";
export interface ScheduleCommandOptions extends CommandOptions {
host?: string;
@@ -47,6 +48,16 @@ export function toScheduleCommandError(code: string, action: string, error: unkn
};
}
export async function requireNewAgentSchedule(
client: ScheduleDaemonClient,
id: string,
): Promise<void> {
const payload = await client.scheduleInspect({ id });
if (payload.error || !payload.schedule || payload.schedule.target.type !== "new-agent") {
throw new Error(payload.error ?? `Schedule not found: ${id}`);
}
}
export function formatCadence(cadence: ScheduleCadence): string {
if (cadence.type === "cron") {
const timezoneSuffix = cadence.timezone ? ` (${cadence.timezone})` : "";
@@ -92,12 +103,7 @@ function resolveScheduleTarget(args: {
createNewAgentTarget: () => ScheduleTarget;
}): ScheduleTarget {
const { targetValue, hasExplicitNewAgentOption, createNewAgentTarget } = args;
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
if (!targetValue) {
if (currentAgentId && !hasExplicitNewAgentOption) {
return { type: "self", agentId: currentAgentId };
}
return createNewAgentTarget();
}
@@ -114,6 +120,9 @@ function resolveScheduleTarget(args: {
}
if (targetValue === "self") {
// COMPAT(scheduleSelfTarget): heartbeat creation moved to `paseo heartbeat create`.
// Added in v0.2.0; remove after 2027-01-17.
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
if (!currentAgentId) {
throw {
code: "INVALID_TARGET",
@@ -211,23 +220,9 @@ export function parseScheduleCreateInput(options: {
function resolveRunOnCreate(
runNow: boolean | undefined,
cadenceType: ScheduleCadence["type"],
_cadenceType: ScheduleCadence["type"],
): boolean {
if (runNow === true && cadenceType === "every") {
throw {
code: "REDUNDANT_RUN_NOW",
message: "--run-now is redundant with --every (interval schedules already fire on creation)",
details: "Drop --run-now, or use --no-run-now to wait the full interval before the first run",
} satisfies CommandError;
}
if (runNow === false && cadenceType === "cron") {
throw {
code: "REDUNDANT_NO_RUN_NOW",
message: "--no-run-now is redundant with --cron (cron schedules never fire on creation)",
details: "Drop --no-run-now, or use --run-now to fire one immediate run on creation",
} satisfies CommandError;
}
return runNow ?? cadenceType === "every";
return runNow ?? false;
}
export interface ScheduleUpdateOptionsInput {
@@ -307,7 +302,7 @@ function parseCadenceFromFlags(
} satisfies CommandError;
}
if (every !== undefined) {
return { type: "every", everyMs: parseDuration(every) };
return { type: "cron", expression: compileEveryPresetToCron(every) };
}
if (cron !== undefined) {
return {
@@ -319,6 +314,20 @@ function parseCadenceFromFlags(
return undefined;
}
export function compileEveryPresetToCron(value: string): string {
const durationMs = parseDuration(value);
const cron = everyMsToFiveFieldCron(durationMs);
if (cron) {
return cron;
}
throw {
code: "UNREPRESENTABLE_CADENCE",
message: `${value} cannot be represented faithfully by five-field cron`,
details: "Use --cron for calendar schedules",
} satisfies CommandError;
}
function parseTimeZoneFlag(timeZone: string | undefined): string | undefined {
if (timeZone === undefined) {
return undefined;

View File

@@ -8,6 +8,7 @@ import {
import {
connectScheduleClient,
parseScheduleUpdateInput,
requireNewAgentSchedule,
toScheduleCommandError,
type ScheduleCommandOptions,
} from "./shared.js";
@@ -51,6 +52,7 @@ export async function runUpdateCommand(
});
const { client } = await connectScheduleClient(options.host);
try {
await requireNewAgentSchedule(client, id);
const payload = await client.scheduleUpdate(input);
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Failed to update schedule: ${id}`);

View File

@@ -0,0 +1,52 @@
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js";
interface WorkspaceArchiveResult {
workspaceId: string;
status: "archived";
archivedAt: string;
}
const workspaceArchiveSchema: OutputSchema<WorkspaceArchiveResult> = {
idField: "workspaceId",
columns: [
{ header: "WORKSPACE ID", field: "workspaceId", width: 20 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "ARCHIVED AT", field: "archivedAt", width: 26 },
],
};
export async function runArchiveCommand(
workspaceId: string,
options: { host?: string },
_command: Command,
): Promise<SingleResult<WorkspaceArchiveResult>> {
const host = getDaemonHost({ host: options.host });
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
} satisfies CommandError;
});
try {
const payload = await client.archiveWorkspace(workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
if (!payload.archivedAt) {
throw new Error("Workspace archive did not return an archive timestamp");
}
return {
type: "single",
data: { workspaceId, status: "archived", archivedAt: payload.archivedAt },
schema: workspaceArchiveSchema,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw { code: "WORKSPACE_ARCHIVE_FAILED", message } satisfies CommandError;
} finally {
await client.close().catch(() => undefined);
}
}

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { buildWorkspaceSource } from "./create.js";
describe("workspace create source", () => {
it("maps local isolation to a directory workspace", () => {
expect(
buildWorkspaceSource({ isolation: "local", path: "/tmp/project", project: "project-1" }),
).toEqual({ kind: "directory", path: "/tmp/project", projectId: "project-1" });
});
it("keeps branch names separate from managed worktree slugs", () => {
expect(
buildWorkspaceSource({
isolation: "worktree",
path: "/tmp/project",
mode: "branch-off",
newBranch: "feature/auth",
worktreeSlug: "feature-auth",
base: "main",
}),
).toEqual({
kind: "worktree",
cwd: "/tmp/project",
action: "branch-off",
branchName: "feature/auth",
worktreeSlug: "feature-auth",
baseBranch: "main",
});
});
it("uses a project as the worktree source without capturing the ambient directory", () => {
expect(
buildWorkspaceSource({
isolation: "worktree",
project: "project-1",
mode: "branch-off",
newBranch: "fix-x",
}),
).toEqual({
kind: "worktree",
projectId: "project-1",
action: "branch-off",
branchName: "fix-x",
});
});
it("checks out an existing branch into a worktree workspace", () => {
expect(
buildWorkspaceSource({
isolation: "worktree",
path: "/tmp/project",
mode: "checkout-branch",
branch: "existing-work",
worktreeSlug: "existing-work-copy",
}),
).toEqual({
kind: "worktree",
cwd: "/tmp/project",
action: "checkout",
refName: "existing-work",
worktreeSlug: "existing-work-copy",
});
});
it("checks out a pull request into a worktree workspace", () => {
expect(
buildWorkspaceSource({
isolation: "worktree",
path: "/tmp/project",
mode: "checkout-pr",
prNumber: "42",
forge: "gitlab",
}),
).toEqual({
kind: "worktree",
cwd: "/tmp/project",
action: "checkout",
checkoutSource: {
kind: "change_request",
forge: "gitlab",
number: 42,
},
});
});
it("lets the source checkout select the forge when it is omitted", () => {
expect(
buildWorkspaceSource({
isolation: "worktree",
path: "/tmp/project",
mode: "checkout-pr",
prNumber: "42",
}),
).toEqual({
kind: "worktree",
cwd: "/tmp/project",
action: "checkout",
checkoutSource: {
kind: "change_request",
number: 42,
},
});
});
it("requires the mode-specific checkout target", () => {
expect(() => buildWorkspaceSource({ isolation: "worktree", mode: "checkout-branch" })).toThrow(
"--branch is required",
);
expect(() => buildWorkspaceSource({ isolation: "worktree", mode: "checkout-pr" })).toThrow(
"--pr-number is required",
);
});
it("rejects worktree options for local isolation", () => {
expect(() => buildWorkspaceSource({ isolation: "local", mode: "branch-off" })).toThrow(
"Worktree options require --isolation worktree",
);
});
it("rejects unknown isolation", () => {
expect(() => buildWorkspaceSource({ isolation: "container" })).toThrow(
"Unsupported workspace isolation",
);
});
});

View File

@@ -0,0 +1,161 @@
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js";
import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js";
export interface WorkspaceCreateOptions extends CommandOptions {
isolation?: string;
path?: string;
project?: string;
title?: string;
mode?: string;
worktreeSlug?: string;
newBranch?: string;
base?: string;
branch?: string;
prNumber?: string;
forge?: string;
}
interface WorktreeSourceBase {
kind: "worktree";
cwd?: string;
projectId?: string;
worktreeSlug?: string;
}
function assertOptionsAbsent(values: unknown[], message: string): void {
if (values.some((value) => value !== undefined)) {
throw new Error(message);
}
}
function buildLocalWorkspaceSource(options: WorkspaceCreateOptions, path: string) {
assertOptionsAbsent(
[
options.mode,
options.worktreeSlug,
options.newBranch,
options.base,
options.branch,
options.prNumber,
options.forge,
],
"Worktree options require --isolation worktree",
);
return {
kind: "directory" as const,
path,
...(options.project ? { projectId: options.project } : {}),
};
}
function buildBranchOffSource(options: WorkspaceCreateOptions, source: WorktreeSourceBase) {
assertOptionsAbsent(
[options.branch, options.prNumber, options.forge],
"--branch, --pr-number, and --forge require a checkout mode",
);
return {
...source,
action: "branch-off" as const,
...(options.newBranch ? { branchName: options.newBranch } : {}),
...(options.base ? { baseBranch: options.base } : {}),
};
}
function buildBranchCheckoutSource(options: WorkspaceCreateOptions, source: WorktreeSourceBase) {
if (!options.branch) {
throw new Error("--branch is required for --mode checkout-branch");
}
assertOptionsAbsent(
[options.newBranch, options.base, options.prNumber, options.forge],
"--new-branch, --base, --pr-number, and --forge are not valid for --mode checkout-branch",
);
return { ...source, action: "checkout" as const, refName: options.branch };
}
function buildPullRequestCheckoutSource(
options: WorkspaceCreateOptions,
source: WorktreeSourceBase,
) {
if (options.prNumber === undefined || options.prNumber === "") {
throw new Error("--pr-number is required for --mode checkout-pr");
}
const prNumber = Number(options.prNumber);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
throw new Error("--pr-number must be a positive integer");
}
assertOptionsAbsent(
[options.newBranch, options.base, options.branch],
"--new-branch, --base, and --branch are not valid for --mode checkout-pr",
);
return {
...source,
action: "checkout" as const,
checkoutSource: {
kind: "change_request" as const,
...(options.forge ? { forge: options.forge } : {}),
number: prNumber,
},
};
}
function buildWorktreeWorkspaceSource(options: WorkspaceCreateOptions, path: string | undefined) {
const source: WorktreeSourceBase = {
kind: "worktree",
...(path ? { cwd: path } : {}),
...(options.project ? { projectId: options.project } : {}),
...(options.worktreeSlug ? { worktreeSlug: options.worktreeSlug } : {}),
};
switch (options.mode ?? "branch-off") {
case "branch-off":
return buildBranchOffSource(options, source);
case "checkout-branch":
return buildBranchCheckoutSource(options, source);
case "checkout-pr":
return buildPullRequestCheckoutSource(options, source);
default:
throw new Error(`Unsupported worktree mode: ${String(options.mode)}`);
}
}
export function buildWorkspaceSource(options: WorkspaceCreateOptions) {
if (options.isolation === "local") {
return buildLocalWorkspaceSource(options, options.path ?? process.cwd());
}
if (options.isolation === "worktree") {
const sourcePath = options.path ?? (options.project ? undefined : process.cwd());
return buildWorktreeWorkspaceSource(options, sourcePath);
}
throw new Error(`Unsupported workspace isolation: ${String(options.isolation)}`);
}
export async function runCreateCommand(
options: WorkspaceCreateOptions,
_command: Command,
): Promise<SingleResult<WorkspaceRow>> {
const host = getDaemonHost({ host: options.host });
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
} satisfies CommandError;
});
try {
const payload = await client.createWorkspace({
source: buildWorkspaceSource(options),
...(options.title ? { title: options.title } : {}),
});
if (!payload.workspace) {
throw new Error(payload.error ?? "Workspace creation failed");
}
return { type: "single", data: toWorkspaceRow(payload.workspace), schema: workspaceSchema };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw { code: "WORKSPACE_CREATE_FAILED", message } satisfies CommandError;
} finally {
await client.close().catch(() => undefined);
}
}

View File

@@ -0,0 +1,43 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { runArchiveCommand } from "./archive.js";
import { runCreateCommand } from "./create.js";
import { runLsCommand } from "./ls.js";
export function createWorkspaceCommand(): Command {
const workspace = new Command("workspace").description("Manage workspaces");
addJsonAndDaemonHostOptions(
workspace
.command("create")
.description("Create a workspace")
.requiredOption("--isolation <local|worktree>", "Workspace isolation")
.option("--path <path>", "Local directory or source checkout (default: current)")
.option("--project <id>", "Existing project id")
.option("--title <title>", "Workspace title")
.option(
"--mode <mode>",
"Worktree mode: branch-off, checkout-branch, or checkout-pr (default: branch-off)",
)
.option("--worktree-slug <slug>", "Managed worktree path slug")
.option("--new-branch <name>", "New branch name (--mode branch-off)")
.option("--base <ref>", "Base ref (--mode branch-off)")
.option("--branch <name>", "Existing branch (--mode checkout-branch)")
.option("--pr-number <n>", "Pull request or change request number (--mode checkout-pr)")
.option("--forge <forge>", "Forge for --mode checkout-pr (default: source checkout)"),
).action(withOutput(runCreateCommand));
addJsonAndDaemonHostOptions(workspace.command("ls").description("List active workspaces")).action(
withOutput(runLsCommand),
);
addJsonAndDaemonHostOptions(
workspace
.command("archive")
.description("Archive a workspace and everything it owns")
.argument("<workspace-id>", "Workspace id"),
).action(withOutput(runArchiveCommand));
return workspace;
}

View File

@@ -0,0 +1,32 @@
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, ListResult } from "../../output/index.js";
import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js";
export async function runLsCommand(
options: { host?: string },
_command: Command,
): Promise<ListResult<WorkspaceRow>> {
const host = getDaemonHost({ host: options.host });
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
} satisfies CommandError;
});
try {
const workspaces: WorkspaceRow[] = [];
let cursor: string | undefined;
do {
const payload = await client.fetchWorkspaces({
page: { limit: 200, ...(cursor ? { cursor } : {}) },
});
workspaces.push(...payload.entries.map(toWorkspaceRow));
cursor = payload.pageInfo.nextCursor ?? undefined;
} while (cursor);
return { type: "list", data: workspaces, schema: workspaceSchema };
} finally {
await client.close().catch(() => undefined);
}
}

View File

@@ -0,0 +1,31 @@
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import type { OutputSchema } from "../../output/index.js";
export interface WorkspaceRow {
workspaceId: string;
project: string;
name: string;
isolation: "local" | "worktree";
cwd: string;
}
export const workspaceSchema: OutputSchema<WorkspaceRow> = {
idField: "workspaceId",
columns: [
{ header: "WORKSPACE ID", field: "workspaceId", width: 20 },
{ header: "PROJECT", field: "project", width: 20 },
{ header: "NAME", field: "name", width: 22 },
{ header: "ISOLATION", field: "isolation", width: 10 },
{ header: "CWD", field: "cwd", width: 42 },
],
};
export function toWorkspaceRow(workspace: WorkspaceDescriptorPayload): WorkspaceRow {
return {
workspaceId: workspace.id,
project: workspace.projectDisplayName,
name: workspace.name,
isolation: workspace.workspaceKind === "worktree" ? "worktree" : "local",
cwd: workspace.workspaceDirectory,
};
}

View File

@@ -1,6 +1,6 @@
/**
* Parse duration string to milliseconds.
* Supports formats like: 5m, 30s, 1h, 2h30m, 90, etc.
* Supports formats like: 5m, 30s, 1h, 2h30m, 1d, 90, etc.
* If no unit is specified, assumes seconds.
*/
export function parseDuration(input: string): number {
@@ -11,14 +11,16 @@ export function parseDuration(input: string): number {
return parseInt(trimmed, 10) * 1000;
}
if (!/^(?:\d+[smhd])+$/.test(trimmed)) {
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m, 1d`);
}
// Parse duration with units
let totalMs = 0;
const regex = /(\d+)([smh])/g;
const regex = /(\d+)([smhd])/g;
let match;
let hasMatch = false;
while ((match = regex.exec(trimmed)) !== null) {
hasMatch = true;
const value = parseInt(match[1], 10);
const unit = match[2];
@@ -32,12 +34,11 @@ export function parseDuration(input: string): number {
case "h":
totalMs += value * 60 * 60 * 1000;
break;
case "d":
totalMs += value * 24 * 60 * 60 * 1000;
break;
}
}
if (!hasMatch) {
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`);
}
return totalMs;
}

View File

@@ -53,7 +53,8 @@ try {
const result = await $`npx paseo run --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "run --help should exit 0");
assert(result.stdout.includes("-d"), "help should mention -d flag");
assert(result.stdout.includes("--detach"), "help should mention --detach flag");
assert(result.stdout.includes("--background"), "help should mention --background flag");
assert(!result.stdout.includes("--detach"), "help should hide legacy --detach syntax");
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");
@@ -174,18 +175,18 @@ try {
console.log("✓ run --output-schema flag is accepted\n");
}
// Test 10: run --output-schema cannot be used with --detach
// Test 10: run --output-schema cannot be used with --background
{
console.log("Test 10: run --output-schema cannot be used with --detach");
console.log("Test 10: run --output-schema cannot be used with --background");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d --output-schema ${schemaPath} "test prompt"`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail with --detach and --output-schema");
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --background --output-schema ${schemaPath} "test prompt"`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail with --background and --output-schema");
const output = result.stdout + result.stderr;
assert(
output.includes("--output-schema cannot be used with --detach"),
"error should explain detach incompatibility",
output.includes("--output-schema cannot be used with --background"),
"error should explain background incompatibility",
);
console.log("✓ run --output-schema cannot be used with --detach\n");
console.log("✓ run --output-schema cannot be used with --background\n");
}
// Test 11: -q (quiet) flag is accepted with run

View File

@@ -153,13 +153,13 @@ try {
console.log("✓ --json flag is accepted with worktree ls\n");
}
// Test 11: paseo --help shows worktree subcommand
// Test 11: paseo --help keeps the compatibility command hidden
{
console.log("Test 11: paseo --help shows worktree subcommand");
console.log("Test 11: paseo --help hides worktree compatibility command");
const result = await $`npx paseo --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
assert(result.stdout.includes("worktree"), "help should mention worktree subcommand");
console.log("✓ paseo --help shows worktree subcommand\n");
assert(!result.stdout.includes("worktree"), "help should not advertise worktree subcommand");
console.log("✓ paseo --help hides worktree compatibility command\n");
}
} finally {
// Clean up temp directory

View File

@@ -53,7 +53,7 @@ try {
assert.strictEqual(created.exitCode, 0, created.stderr);
const createdJson = JSON.parse(created.stdout);
assert.strictEqual(createdJson.name, "review-prs");
assert.strictEqual(createdJson.cadence, "every:5m");
assert.strictEqual(createdJson.cadence, "cron:*/5 * * * *");
assert(
typeof createdJson.target === "string" &&
(createdJson.target.startsWith("agent:") || createdJson.target === "new-agent:claude"),
@@ -144,6 +144,31 @@ try {
console.log("schedule rejects provider with self target\n");
}
{
console.log("Test 1d: compatibility agent-target schedules remain deletable");
const created = await ctx.paseo(
[
"schedule",
"create",
"Legacy heartbeat",
"--cron",
"0 0 1 1 *",
"--target",
"00000000-0000-4000-8000-000000000001",
"--json",
],
{ timeout: 30000 },
);
assert.strictEqual(created.exitCode, 0, created.stderr);
const createdJson = JSON.parse(created.stdout);
assert.strictEqual(createdJson.target, "agent:0000000");
const deleted = await ctx.paseo(["schedule", "delete", createdJson.id, "--json"]);
assert.strictEqual(deleted.exitCode, 0, deleted.stderr);
assert.strictEqual(JSON.parse(deleted.stdout).id, createdJson.id);
console.log("compatibility agent-target schedules remain deletable\n");
}
{
console.log("Test 2: loop run/ls/inspect/logs/stop work");
const run = await ctx.paseo(

View File

@@ -723,7 +723,7 @@ test("sends new-agent run options when creating schedules", async () => {
const createPromise = client.scheduleCreate({
requestId: "request-1",
prompt: "Run the task",
cadence: { type: "every", everyMs: 60_000 },
cadence: { type: "cron", expression: "* * * * *" },
target: {
type: "new-agent",
config: {
@@ -741,7 +741,7 @@ test("sends new-agent run options when creating schedules", async () => {
type: "schedule/create",
requestId: "request-1",
prompt: "Run the task",
cadence: { type: "every", everyMs: 60_000 },
cadence: { type: "cron", expression: "* * * * *" },
target: {
type: "new-agent",
config: {
@@ -1991,7 +1991,7 @@ test("normalizes workspace_setup_progress into a workspace-scoped daemon event",
});
});
test("sends create_agent_request with string workspace ids", async () => {
test("sends create_agent_request with workspace and caller identity", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -2012,6 +2012,7 @@ test("sends create_agent_request with string workspace ids", async () => {
provider: "codex",
cwd: "/tmp/project/.paseo/worktrees/feature-a",
workspaceId: "ws-feature-a",
callerAgentId: "parent-agent",
title: "Compat agent",
modeId: "default",
});
@@ -2022,6 +2023,7 @@ test("sends create_agent_request with string workspace ids", async () => {
expect.objectContaining({
type: "create_agent_request",
workspaceId: "ws-feature-a",
callerAgentId: "parent-agent",
}),
);

View File

@@ -338,6 +338,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
cwd?: string;
env?: CreateAgentRequestMessage["env"];
workspaceId?: string;
callerAgentId?: string;
initialPrompt?: string;
clientMessageId?: string;
outputSchema?: Record<string, unknown>;
@@ -346,6 +347,8 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
git?: GitSetupOptions;
worktree?: CreateAgentRequestMessage["worktree"];
autoArchive?: CreateAgentRequestMessage["autoArchive"];
// COMPAT(createAgentWorktree): low-level old callers may still send the
// create-agent worktree field. Added in v0.2.0; remove after 2027-01-17.
worktreeName?: string;
requestId?: string;
labels?: Record<string, string>;
@@ -739,16 +742,11 @@ export interface StopLoopOptions {
export interface CreateScheduleOptions {
prompt: string;
name?: string | null;
cadence:
| {
type: "every";
everyMs: number;
}
| {
type: "cron";
expression: string;
timezone?: string;
};
cadence: {
type: "cron";
expression: string;
timezone?: string;
};
target:
| {
type: "self";
@@ -800,16 +798,11 @@ export interface UpdateScheduleOptions {
id: string;
name?: string | null;
prompt?: string;
cadence?:
| {
type: "every";
everyMs: number;
}
| {
type: "cron";
expression: string;
timezone?: string;
};
cadence?: {
type: "cron";
expression: string;
timezone?: string;
};
newAgentConfig?: UpdateScheduleNewAgentConfig;
maxRuns?: number | null;
expiresAt?: string | null;
@@ -2290,6 +2283,7 @@ export class DaemonClient {
config,
...(options.env ? { env: options.env } : {}),
...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
...(options.callerAgentId !== undefined ? { callerAgentId: options.callerAgentId } : {}),
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),

View File

@@ -168,13 +168,13 @@ export interface PaseoAgentCreateOptions extends PaseoAgentConfigOverrides {
provider?: CreateAgentRequestMessage["config"]["provider"];
cwd?: string;
workspaceId?: string;
callerAgentId?: string;
initialPrompt?: string;
clientMessageId?: string;
outputSchema?: Record<string, unknown>;
images?: CreateAgentRequestMessage["images"];
attachments?: CreateAgentRequestMessage["attachments"];
git?: CreateAgentRequestMessage["git"];
worktreeName?: string;
requestId?: string;
labels?: Record<string, string>;
}

View File

@@ -981,7 +981,7 @@ const AgentAttachmentsSchema = z.unknown().transform(normalizeAgentAttachments).
export const ChangeRequestCheckoutSourceSchema = z.object({
kind: z.literal("change_request"),
forge: z.string().optional().default("github"),
forge: z.string().optional(),
number: z.number().int().positive(),
projectPath: z.string().optional(),
});
@@ -1249,6 +1249,9 @@ export const CreateAgentRequestMessageSchema = z.object({
config: AgentSessionConfigSchema,
env: z.record(z.string(), z.string()).optional(),
workspaceId: z.string().optional(),
// Optional caller context lets managed CLI invocations use the same daemon-owned
// workspace and parentage policy as agent-scoped MCP creation.
callerAgentId: z.string().optional(),
worktreeName: z.string().optional(),
initialPrompt: z.string().optional(),
clientMessageId: z.string().optional(),
@@ -2061,9 +2064,11 @@ export const WorkspaceCreateRequestSchema = z.object({
cwd: z.string().optional(),
projectId: z.string().optional(),
action: z.enum(["branch-off", "checkout"]).optional(),
// Target branch name for checkout, or new branch name for branch-off.
// Target branch for checkout, or base ref for branch-off.
refName: z.string().min(1).optional(),
baseBranch: z.string().optional(),
// New branch name for branch-off. The worktree path may use a different slug.
branchName: z.string().min(1).optional(),
checkoutSource: ChangeRequestCheckoutSourceSchema.optional(),
// COMPAT(githubPrNumber): added in v0.1.106, remove after 2026-12-28 once
// clients send checkoutSource.

View File

@@ -1031,6 +1031,25 @@ describe("workspace message schemas", () => {
expect(newWorktree.type).toBe("workspace.create.request");
expect(newWorktree.source.kind).toBe("worktree");
const branchOff = WorkspaceCreateRequestSchema.parse({
type: "workspace.create.request",
requestId: "req-branch-off",
source: {
kind: "worktree",
cwd: "/tmp/repo",
action: "branch-off",
branchName: "feature/auth",
worktreeSlug: "feature-auth",
},
});
expect(branchOff.source).toEqual({
kind: "worktree",
cwd: "/tmp/repo",
action: "branch-off",
branchName: "feature/auth",
worktreeSlug: "feature-auth",
});
// Directory source must also be accepted.
const newDirectory = WorkspaceCreateRequestSchema.parse({
type: "workspace.create.request",

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { everyMsToFiveFieldCron } from "./cadence.js";
describe("everyMsToFiveFieldCron", () => {
it.each([
[60_000, "*/1 * * * *"],
[15 * 60_000, "*/15 * * * *"],
[60 * 60_000, "0 * * * *"],
[6 * 60 * 60_000, "0 */6 * * *"],
[24 * 60 * 60_000, "0 0 * * *"],
])("converts %i milliseconds", (everyMs, cron) => {
expect(everyMsToFiveFieldCron(everyMs)).toBe(cron);
});
it.each([30_000, 7 * 60_000, 5 * 60 * 60_000, 48 * 60 * 60_000])(
"rejects unrepresentable interval %i",
(everyMs) => {
expect(everyMsToFiveFieldCron(everyMs)).toBeNull();
},
);
});

View File

@@ -0,0 +1,27 @@
/**
* Convert an exact rolling interval to an equivalent five-field cron cadence.
* Returns null when cron's calendar boundaries would change the interval.
*/
export function everyMsToFiveFieldCron(everyMs: number): string | null {
const minutes = everyMs / 60_000;
if (!Number.isInteger(minutes) || minutes <= 0) {
return null;
}
if (minutes < 60 && 60 % minutes === 0) {
return `*/${minutes} * * * *`;
}
if (minutes === 60) {
return "0 * * * *";
}
if (minutes % 60 !== 0) {
return null;
}
const hours = minutes / 60;
if (hours < 24 && 24 % hours === 0) {
return `0 */${hours} * * *`;
}
if (hours === 24) {
return "0 0 * * *";
}
return null;
}

View File

@@ -38,13 +38,17 @@ function createCapturedLogger(): CapturedLogger {
interface FinishNotificationScenarioOptions {
childLastAssistantMessage?: string | null;
childParentAgentId?: string | null;
requireParentOwnership?: boolean;
parentPromptError?: Error;
logger?: Logger;
}
interface FinishNotificationScenario {
startWatchingChild(): void;
finishChild(): void;
finishChildAndReadParentPrompt(): Promise<string>;
wasParentPrompted(): boolean;
}
function createFinishNotificationScenario(
@@ -52,6 +56,7 @@ function createFinishNotificationScenario(
): FinishNotificationScenario {
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
let resolveParentPrompt: ((prompt: string) => void) | null = null;
let parentPrompted = false;
const childAgent: ManagedAgent = Object.create(null);
Reflect.set(childAgent, "id", "child-agent");
@@ -85,6 +90,7 @@ function createFinishNotificationScenario(
Reflect.set(agentManager, "tryRunOutOfBand", () => false);
Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError));
Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => {
parentPrompted = true;
resolveParentPrompt?.(prompt);
return (async function* noop() {})();
});
@@ -96,7 +102,12 @@ function createFinishNotificationScenario(
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
Reflect.set(agentStorage, "get", async (agentId: string) => {
if (agentId === "child-agent") {
return { title: "Child Agent" };
const parentAgentId =
options?.childParentAgentId === undefined ? "caller-agent" : options.childParentAgentId;
return {
title: "Child Agent",
labels: parentAgentId ? { "paseo.parent-agent-id": parentAgentId } : {},
};
}
return null;
});
@@ -108,14 +119,11 @@ function createFinishNotificationScenario(
agentStorage,
childAgentId: "child-agent",
callerAgentId: "caller-agent",
requireParentOwnership: options?.requireParentOwnership,
logger: options?.logger ?? createTestLogger(),
});
},
async finishChildAndReadParentPrompt() {
const parentPrompt = new Promise<string>((resolve) => {
resolveParentPrompt = resolve;
});
finishChild() {
childAgent.lifecycle = "running";
subscriber?.({
type: "agent_state",
@@ -127,9 +135,18 @@ function createFinishNotificationScenario(
type: "agent_state",
agent: childAgent,
});
},
async finishChildAndReadParentPrompt() {
const parentPrompt = new Promise<string>((resolve) => {
resolveParentPrompt = resolve;
});
this.finishChild();
return parentPrompt;
},
wasParentPrompted() {
return parentPrompted;
},
};
}
@@ -192,6 +209,26 @@ test("finish notifications tell the parent the child's last assistant message",
);
});
test("detaching a child ends its parent-owned finish notification", async () => {
const scenario = createFinishNotificationScenario({
childParentAgentId: null,
requireParentOwnership: true,
});
scenario.startWatchingChild();
scenario.finishChild();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(scenario.wasParentPrompted()).toBe(false);
});
test("follow-up finish notifications do not require a parent relationship", async () => {
const scenario = createFinishNotificationScenario({ childParentAgentId: "another-agent" });
scenario.startWatchingChild();
const parentPrompt = await scenario.finishChildAndReadParentPrompt();
expect(parentPrompt).toContain("Agent child-agent (Child Agent) finished.");
});
test("finish notifications log a rejected parent prompt without an unhandled rejection", async () => {
const captured = createCapturedLogger();
const scenario = createFinishNotificationScenario({

View File

@@ -4,6 +4,7 @@ import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
export type AgentUnarchiveController = Pick<AgentManager, "notifyAgentState" | "unarchiveSnapshot">;
@@ -244,6 +245,7 @@ export interface SetupFinishNotificationParams {
agentStorage: AgentStorage;
childAgentId: string;
callerAgentId: string;
requireParentOwnership?: boolean;
logger: Logger;
}
@@ -264,7 +266,14 @@ function formatFinishNotificationBody(params: FinishNotificationBodyInput): stri
}
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
const { agentManager, agentStorage, childAgentId, callerAgentId, logger } = params;
const {
agentManager,
agentStorage,
childAgentId,
callerAgentId,
requireParentOwnership = false,
logger,
} = params;
let hasSeenRunning = false;
let fired = false;
let unsubscribe: (() => void) | null = null;
@@ -282,6 +291,9 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
}
const record = await agentStorage.get(childAgentId);
if (requireParentOwnership && getParentAgentIdFromLabels(record?.labels) !== callerAgentId) {
return;
}
const title = record?.title ?? childAgentId;
const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId);
const body = formatFinishNotificationBody({

View File

@@ -1,6 +1,5 @@
import type { Logger } from "pino";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js";
import { expandUserPath, resolvePathFromBase } from "../../path-utils.js";
@@ -26,6 +25,7 @@ import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "../timeline-append.js";
import { resolveCreateAgentIntent } from "./intent.js";
export interface CreateAgentSessionWorktreeResult {
sessionConfig: AgentSessionConfig;
@@ -208,6 +208,7 @@ export async function createAgentCommand(
agentStorage: dependencies.agentStorage,
childAgentId: snapshot.id,
callerAgentId: input.callerAgentId,
requireParentOwnership: true,
logger: dependencies.logger,
});
}
@@ -317,13 +318,21 @@ async function resolveMcpCreateAgent(
});
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
const workspaceId = await resolveMcpWorkspaceId({
dependencies,
input,
parentAgent,
setupContinuation,
createdWorkspaceId,
resolvedCwd,
const intent = await resolveCreateAgentIntent({
explicitWorkspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId,
caller: parentAgent
? { id: parentAgent.id, cwd: parentAgent.cwd, workspaceId: parentAgent.workspaceId }
: null,
labels: input.labels,
childAgentDefaultLabels: input.callerContext?.childAgentDefaultLabels,
legacyDetached: input.detached ?? false,
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: resolvedCwd }),
createWorkspace: async () => ({
workspaceId: requireResolvedWorkspaceId(
await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd, input.initialPrompt ?? ""),
),
cwd: resolvedCwd,
}),
});
const resolvedCreateConfig = await resolveMcpProviderCreateConfig({
dependencies,
@@ -333,27 +342,20 @@ async function resolveMcpCreateAgent(
parentAgent,
});
const labels = mergeLabels({
callerAgentId: input.callerAgentId,
detached: input.detached ?? false,
childAgentDefaultLabels: input.callerContext?.childAgentDefaultLabels,
labels: input.labels,
});
const trimmedPrompt = input.initialPrompt?.trim() ?? "";
return {
config: buildMcpSessionConfig({
input,
resolvedProviderModel,
provider,
resolvedCwd,
resolvedCwd: intent.cwd,
trimmedPrompt,
resolvedMode: resolvedCreateConfig.modeId,
resolvedFeatures: resolvedCreateConfig.featureValues,
}),
createOptions: {
...(labels ? { labels } : {}),
workspaceId: requireResolvedWorkspaceId(workspaceId),
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
workspaceId: intent.workspaceId,
owner: input.owner,
env: input.env,
},
@@ -380,34 +382,6 @@ function resolveMcpInitialCwd(
});
}
async function resolveMcpWorkspaceId(params: {
dependencies: CreateAgentCommandDependencies;
input: CreateAgentFromMcpInput;
parentAgent: ManagedAgent | null;
setupContinuation?: AgentWorktreeSetupContinuation;
createdWorkspaceId?: string;
resolvedCwd: string;
}): Promise<string | undefined> {
// MCP callers resolve workspace ownership before this point. Worktree
// creation wins because the new agent lives in the fresh worktree workspace.
// Otherwise use the explicit workspace id, then the parent workspace for
// direct internal callers. Ownership is never resolved from cwd.
if (params.setupContinuation) {
return params.createdWorkspaceId;
}
if (params.input.workspaceId) {
return params.input.workspaceId;
}
if (params.parentAgent?.workspaceId) {
return params.parentAgent.workspaceId;
}
return ensureWorkspaceForMcpCreate(
params.dependencies,
params.resolvedCwd,
params.input.initialPrompt ?? "",
);
}
async function resolveMcpProviderCreateConfig(params: {
dependencies: CreateAgentCommandDependencies;
input: CreateAgentFromMcpInput;
@@ -624,22 +598,3 @@ async function createMcpWorktree(
throw toWorktreeRequestError(error);
}
}
function mergeLabels(params: {
callerAgentId: string | undefined;
detached: boolean;
childAgentDefaultLabels: Record<string, string> | undefined;
labels: Record<string, string> | undefined;
}): Record<string, string> | undefined {
const mergedLabels = {
...(!params.detached && params.callerAgentId
? { [PARENT_AGENT_ID_LABEL]: params.callerAgentId }
: {}),
...params.childAgentDefaultLabels,
...params.labels,
};
if (params.detached) {
delete mergedLabels[PARENT_AGENT_ID_LABEL];
}
return Object.keys(mergedLabels).length > 0 ? mergedLabels : undefined;
}

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { resolveCreateAgentIntent } from "./intent.js";
describe("resolveCreateAgentIntent", () => {
it("keeps caller parentage when an explicit workspace changes placement", async () => {
const intent = await resolveCreateAgentIntent({
explicitWorkspaceId: "workspace-isolated",
caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" },
labels: { purpose: "review" },
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/isolated" }),
createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }),
});
expect(intent).toEqual({
workspaceId: "workspace-isolated",
cwd: "/isolated",
parentAgentId: "parent-agent",
labels: {
purpose: "review",
[PARENT_AGENT_ID_LABEL]: "parent-agent",
},
});
});
it("defaults an agent caller to its workspace without creating one", async () => {
let createCount = 0;
const intent = await resolveCreateAgentIntent({
caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" },
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }),
createWorkspace: async () => {
createCount += 1;
return { workspaceId: "workspace-created", cwd: "/created" };
},
});
expect(intent.workspaceId).toBe("workspace-parent");
expect(intent.cwd).toBe("/parent");
expect(intent.parentAgentId).toBe("parent-agent");
expect(createCount).toBe(0);
});
it("creates a workspace for a human caller with no workspace context", async () => {
const intent = await resolveCreateAgentIntent({
caller: null,
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }),
createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }),
});
expect(intent).toEqual({
workspaceId: "workspace-created",
cwd: "/created",
parentAgentId: null,
labels: {},
});
});
it("keeps legacy detached creation independent", async () => {
const intent = await resolveCreateAgentIntent({
caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" },
labels: { [PARENT_AGENT_ID_LABEL]: "spoofed-parent" },
legacyDetached: true,
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }),
createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }),
});
expect(intent).toEqual({
workspaceId: "workspace-parent",
cwd: "/parent",
parentAgentId: null,
labels: {},
});
});
});

View File

@@ -0,0 +1,64 @@
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
export interface CreateAgentCaller {
id: string;
cwd: string;
workspaceId?: string;
}
export interface CreateAgentPlacement {
workspaceId: string;
cwd: string;
}
export interface CreateAgentIntent {
workspaceId: string;
cwd: string;
parentAgentId: string | null;
labels: Record<string, string>;
}
export async function resolveCreateAgentIntent(input: {
explicitWorkspaceId?: string;
caller: CreateAgentCaller | null;
labels?: Record<string, string>;
childAgentDefaultLabels?: Record<string, string>;
resolveWorkspace: (workspaceId: string) => Promise<CreateAgentPlacement>;
createWorkspace: () => Promise<CreateAgentPlacement>;
legacyDetached?: boolean;
}): Promise<CreateAgentIntent> {
const parentAgentId = input.legacyDetached ? null : (input.caller?.id ?? null);
const placement = await resolvePlacement(input);
const labels = {
...input.childAgentDefaultLabels,
...input.labels,
...(parentAgentId ? { [PARENT_AGENT_ID_LABEL]: parentAgentId } : {}),
};
// COMPAT(detachedCreate): legacy callers may still request detached creation.
// Added in v0.2.0; remove after 2027-01-17 once detached creation is outside the floor.
// The delete also strips a parent label injected through input.labels.
if (input.legacyDetached) {
delete labels[PARENT_AGENT_ID_LABEL];
}
return { ...placement, parentAgentId, labels };
}
async function resolvePlacement(input: {
explicitWorkspaceId?: string;
caller: CreateAgentCaller | null;
resolveWorkspace: (workspaceId: string) => Promise<CreateAgentPlacement>;
createWorkspace: () => Promise<CreateAgentPlacement>;
}): Promise<CreateAgentPlacement> {
if (input.explicitWorkspaceId) {
return input.resolveWorkspace(input.explicitWorkspaceId);
}
if (input.caller) {
if (!input.caller.workspaceId) {
throw new Error(`Caller agent ${input.caller.id} has no workspace`);
}
return { workspaceId: input.caller.workspaceId, cwd: input.caller.cwd };
}
return input.createWorkspace();
}

View File

@@ -1202,6 +1202,42 @@ describe("create_agent MCP tool", () => {
).toBe(true);
});
it("creates a fresh local workspace for canonical top-level creation", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "top-level-agent",
provider: "codex",
cwd: existingCwd,
workspaceId: "workspace-created",
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Top-level agent" },
} as ManagedAgent);
const ensureWorkspace = vi.fn(async () => "workspace-created");
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate: ensureWorkspace,
logger,
});
await registeredTool(server, "create_agent").handler({
title: "Top-level agent",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
});
expect(ensureWorkspace).toHaveBeenCalledWith(existingCwd, { prompt: "Do work" });
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ cwd: existingCwd }),
undefined,
{ workspaceId: "workspace-created" },
);
});
it("rejects partial explicit workspace shape", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
@@ -1508,7 +1544,7 @@ describe("create_agent MCP tool", () => {
expect(parsed.success).toBe(true);
});
it("accepts each create_worktree target kind", async () => {
it("exposes workspace tools instead of worktree tools", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
@@ -1516,31 +1552,14 @@ describe("create_agent MCP tool", () => {
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "create_worktree");
for (const target of [
{ kind: "branch-off", worktreeSlug: "feature-x", baseBranch: "main" },
{ kind: "branch-off" },
{ kind: "checkout-branch", branch: "head-ref" },
{ kind: "checkout-pr", githubPrNumber: 42 },
] as const) {
const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, target });
expect(parsed.success).toBe(true);
}
});
it("rejects create_worktree without a target", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "create_worktree");
const parsed = await tool.inputSchema.safeParseAsync({});
expect(parsed.success).toBe(false);
expect(lookupTool(server, "create_workspace")).toBeDefined();
expect(lookupTool(server, "list_workspaces")).toBeDefined();
expect(lookupTool(server, "archive_workspace")).toBeDefined();
expect(lookupTool(server, "create_worktree")).toBeUndefined();
expect(lookupTool(server, "list_worktrees")).toBeUndefined();
expect(lookupTool(server, "archive_worktree")).toBeUndefined();
expect(lookupTool(server, "detach_agent")).toBeUndefined();
expect(lookupTool(server, "update_heartbeat")).toBeUndefined();
});
it("surfaces createAgent validation failures", async () => {
@@ -2443,7 +2462,7 @@ describe("create_agent MCP tool", () => {
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
});
it("registers and broadcasts a workspace when create_worktree creates a worktree", async () => {
it("creates a worktree-isolated workspace", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-"));
const repoDir = join(tempDir, "repo");
@@ -2488,25 +2507,19 @@ describe("create_agent MCP tool", () => {
>,
logger,
});
const tool = registeredTool(server, "create_worktree");
const tool = registeredTool(server, "create_workspace");
const response = await tool.handler({
cwd: repoDir,
target: {
kind: "branch-off",
worktreeSlug: "tool-worktree",
branchName: "feature/tool-worktree",
baseBranch: "main",
},
isolation: "worktree",
path: repoDir,
worktreeSlug: "tool-worktree",
branchName: "feature/tool-worktree",
baseBranch: "main",
});
expect(response.structuredContent.branchName).toBe("feature/tool-worktree");
expect(response.structuredContent.worktreePath).toContain("tool-worktree");
expect(response.structuredContent.isolation).toBe("worktree");
expect(response.structuredContent.cwd).toContain("tool-worktree");
expect(response.structuredContent.workspaceId).toBe(broadcasts[0]);
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "mcp:create-worktree",
});
expect(setupContinuations).toEqual([undefined]);
expect(broadcasts).toHaveLength(1);
expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
@@ -2515,7 +2528,149 @@ describe("create_agent MCP tool", () => {
}
});
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
it("creates a worktree workspace from a project root without a path", async () => {
const { agentManager, agentStorage } = createTestDeps();
const project = createPersistedProjectRecord({
projectId: "project-source",
rootPath: REPO_CWD,
kind: "git",
displayName: "source",
createdAt: "2026-07-18T00:00:00.000Z",
updatedAt: "2026-07-18T00:00:00.000Z",
});
const receivedInputs: CreatePaseoWorktreeInput[] = [];
const createPaseoWorktree: CreatePaseoWorktreeWorkflowFn = async (input) => {
receivedInputs.push(input);
return {
worktree: { branchName: "project-worktree", worktreePath: TARGET_CWD },
intent: { kind: "branch-off", branchName: "project-worktree", baseBranch: "main" },
workspace: createPersistedWorkspaceRecord({
workspaceId: "ws-project-source",
projectId: project.projectId,
cwd: TARGET_CWD,
kind: "worktree",
displayName: "project-worktree",
title: input.title ?? null,
createdAt: "2026-07-18T00:00:00.000Z",
updatedAt: "2026-07-18T00:00:00.000Z",
}),
repoRoot: REPO_CWD,
created: true,
};
};
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
projectRegistry: {
get: async (projectId) => (projectId === project.projectId ? project : null),
},
createPaseoWorktree,
logger,
});
const response = await invokeToolWithParsedInput(registeredTool(server, "create_workspace"), {
isolation: "worktree",
projectId: project.projectId,
worktreeSlug: "project-worktree",
title: "Project workspace",
});
expect(response.structuredContent.workspaceId).toBe("ws-project-source");
expect(receivedInputs).toEqual([
expect.objectContaining({
cwd: REPO_CWD,
projectId: project.projectId,
title: "Project workspace",
}),
]);
});
it("preserves branch checkout and pull request checkout workspace modes", async () => {
const { agentManager, agentStorage } = createTestDeps();
const createPaseoWorktree = vi.fn(async (input: CreatePaseoWorktreeInput) => ({
worktree: {
branchName: input.refName ?? "pr-42",
worktreePath: "/tmp/worktrees/selected",
},
intent: {
kind: "checkout-branch" as const,
branchName: input.refName ?? "pr-42",
},
workspace: createPersistedWorkspaceRecord({
workspaceId: "ws-selected",
projectId: "project-1",
cwd: "/tmp/worktrees/selected",
kind: "worktree",
displayName: "selected",
createdAt: "2026-07-18T00:00:00.000Z",
updatedAt: "2026-07-18T00:00:00.000Z",
}),
repoRoot: REPO_CWD,
created: true,
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
createPaseoWorktree,
logger,
});
const tool = registeredTool(server, "create_workspace");
await invokeToolWithParsedInput(tool, {
isolation: "worktree",
path: REPO_CWD,
mode: "checkout-branch",
branch: "existing-work",
worktreeSlug: "existing-work-copy",
});
await invokeToolWithParsedInput(tool, {
isolation: "worktree",
path: REPO_CWD,
mode: "checkout-pr",
prNumber: 42,
forge: "gitlab",
});
await invokeToolWithParsedInput(tool, {
isolation: "worktree",
path: REPO_CWD,
mode: "checkout-pr",
prNumber: 43,
});
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
action: "checkout",
refName: "existing-work",
worktreeSlug: "existing-work-copy",
}),
);
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
action: "checkout",
checkoutSource: {
kind: "change_request",
forge: "gitlab",
number: 42,
},
}),
);
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
action: "checkout",
checkoutSource: {
kind: "change_request",
number: 43,
},
}),
);
});
it("archives a worktree-isolated workspace by workspace id", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")),
@@ -2568,13 +2723,15 @@ describe("create_agent MCP tool", () => {
github: createGitHubServiceStub(),
logger,
});
const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree");
const createTool = registeredTool(server, "create_workspace");
const archiveTool = registeredTool(server, "archive_workspace");
const created = await createTool.handler({
cwd: repoDir,
target: { kind: "branch-off", worktreeSlug: "archive-tool-worktree", baseBranch: "main" },
isolation: "worktree",
path: repoDir,
worktreeSlug: "archive-tool-worktree",
baseBranch: "main",
});
const createdWorktreePath = z.string().parse(created.structuredContent.worktreePath);
const createdWorktreePath = z.string().parse(created.structuredContent.cwd);
listActiveWorkspaces.mockImplementation(async () => [
{ workspaceId: "ws-archive-tool-worktree", cwd: createdWorktreePath, kind: "worktree" },
]);
@@ -2584,19 +2741,13 @@ describe("create_agent MCP tool", () => {
workspaceGitService.getSnapshot.mockClear();
await archiveTool.handler({
cwd: repoDir,
worktreePath: created.structuredContent.worktreePath,
workspaceId: "ws-archive-tool-worktree",
});
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "archive-worktree",
});
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir);
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "mcp:archive-worktree",
});
expect(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree");
expect(markWorkspaceArchiving).toHaveBeenCalledWith(
["ws-archive-tool-worktree"],
@@ -2611,7 +2762,22 @@ describe("create_agent MCP tool", () => {
}
});
it("archives every workspace on a directory and removes the directory", async () => {
it("rejects archiving a missing workspace", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
listActiveWorkspaces: async () => [],
logger,
});
await expect(
registeredTool(server, "archive_workspace").handler({ workspaceId: "missing-workspace" }),
).rejects.toThrow("Workspace not found: missing-workspace");
});
it("keeps an owned worktree while another workspace still references it", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-multi-")),
@@ -2670,13 +2836,15 @@ describe("create_agent MCP tool", () => {
github: createGitHubServiceStub(),
logger,
});
const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree");
const createTool = registeredTool(server, "create_workspace");
const archiveTool = registeredTool(server, "archive_workspace");
const created = await createTool.handler({
cwd: repoDir,
target: { kind: "branch-off", worktreeSlug: "archive-multi-worktree", baseBranch: "main" },
isolation: "worktree",
path: repoDir,
worktreeSlug: "archive-multi-worktree",
baseBranch: "main",
});
const worktreePath = z.string().parse(created.structuredContent.worktreePath);
const worktreePath = z.string().parse(created.structuredContent.cwd);
// Populate the active workspaces with the real created path so archiveByScope
// matches it against the worktree directory.
@@ -2686,19 +2854,18 @@ describe("create_agent MCP tool", () => {
];
await archiveTool.handler({
cwd: repoDir,
worktreePath,
workspaceId: "ws-mcp-A",
});
expect(archivedWorkspaceIds).toContain("ws-mcp-A");
expect(archivedWorkspaceIds).toContain("ws-mcp-B");
await expect(access(worktreePath)).rejects.toThrow();
expect(archivedWorkspaceIds).not.toContain("ws-mcp-B");
await expect(access(worktreePath)).resolves.toBeUndefined();
} finally {
await removeTempDir(tempDir);
}
});
it("archives a worktree by slug", async () => {
it("does not expose worktree path or slug operations", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-slug-")),
@@ -2746,71 +2913,41 @@ describe("create_agent MCP tool", () => {
github: createGitHubServiceStub(),
logger,
});
const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.handler({
cwd: repoDir,
target: { kind: "branch-off", worktreeSlug: "archive-slug-worktree", baseBranch: "main" },
});
const response = await archiveTool.handler({
cwd: repoDir,
worktreeSlug: "archive-slug-worktree",
});
expect(response.structuredContent).toEqual({ success: true });
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "archive-worktree",
});
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir);
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "mcp:archive-worktree",
});
await expect(
access(z.string().parse(created.structuredContent.worktreePath)),
).rejects.toThrow();
expect(lookupTool(server, "create_worktree")).toBeUndefined();
expect(lookupTool(server, "archive_worktree")).toBeUndefined();
} finally {
await removeTempDir(tempDir);
}
});
it("routes list_worktrees through WorkspaceGitService", async () => {
it("lists active workspace descriptors", async () => {
const { agentManager, agentStorage } = createTestDeps();
const workspaceGitService = {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => [
{
path: "/tmp/paseo/worktrees/repo/feature",
branchName: "feature",
createdAt: "2026-04-12T00:00:00.000Z",
},
]),
};
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-feature",
projectId: "project-1",
cwd: "/tmp/paseo/worktrees/repo/feature",
kind: "worktree",
displayName: "feature",
createdAt: "2026-07-17T00:00:00.000Z",
updatedAt: "2026-07-17T00:00:00.000Z",
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
workspaceRegistry: {
get: vi.fn(async () => workspace),
list: vi.fn(async () => [workspace]),
upsert: vi.fn(async () => undefined),
},
logger,
});
const tool = registeredTool(server, "list_worktrees");
const tool = registeredTool(server, "list_workspaces");
const response = await tool.handler({ cwd: REPO_CWD });
const response = await tool.handler({});
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(REPO_CWD, {
reason: "mcp:list-worktrees",
});
expect(response.structuredContent.worktrees).toEqual([
{
path: "/tmp/paseo/worktrees/repo/feature",
branchName: "feature",
createdAt: "2026-04-12T00:00:00.000Z",
},
expect(response.structuredContent.workspaces).toEqual([
expect.objectContaining({ workspaceId: "ws-feature", isolation: "worktree" }),
]);
});
@@ -3835,11 +3972,11 @@ describe("create_schedule MCP tool", () => {
cron: "*/5 * * * *",
name: "Default schedule",
}),
).rejects.toThrow("provider is required when target is new-agent");
).rejects.toThrow("provider");
expect(createOrReplace).not.toHaveBeenCalled();
});
it("keeps create_schedule provider overrides compatible with provider and provider/model forms", async () => {
it("keeps provider forms compatible without materializing default schedule isolation", async () => {
const { agentManager, agentStorage } = createTestDeps();
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
createStoredSchedule(input),
@@ -3863,6 +4000,12 @@ describe("create_schedule MCP tool", () => {
cron: "*/10 * * * *",
provider: "codex/gpt-5.4",
});
await tool.handler({
prompt: "say hello in a worktree",
cron: "*/15 * * * *",
provider: "codex",
isolation: "worktree",
});
expect(createOrReplace).toHaveBeenNthCalledWith(
1,
@@ -3889,9 +4032,22 @@ describe("create_schedule MCP tool", () => {
},
}),
);
expect(createOrReplace).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
target: {
type: "new-agent",
config: {
provider: "codex",
cwd: process.cwd(),
isolation: "worktree",
},
},
}),
);
});
it("returns create_schedule structured content with inherited feature values", async () => {
it("inherits the caller provider, model, and features when provider is omitted", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
@@ -3922,12 +4078,17 @@ describe("create_schedule MCP tool", () => {
const response = await tool.handler({
prompt: "say hello",
cron: "*/5 * * * *",
provider: "opencode/openai/gpt-5.5",
});
expect(response.structuredContent.target).toMatchObject({
expect(response.structuredContent.target).toEqual({
type: "new-agent",
config: { featureValues: { auto_accept: true } },
config: {
provider: "opencode",
cwd: REPO_CWD,
modeId: "build",
model: "openai/gpt-5.5",
featureValues: { auto_accept: true },
},
});
});
@@ -4103,6 +4264,60 @@ describe("create_heartbeat MCP tool", () => {
});
});
describe("heartbeat ownership MCP tools", () => {
const logger = createTestLogger();
it("deletes the caller's heartbeat", async () => {
const { agentManager, agentStorage } = createTestDeps();
const heartbeat = createStoredSchedule({
prompt: "check status",
cadence: { type: "cron", expression: "*/15 * * * *" },
target: { type: "agent", agentId: "parent-agent" },
});
const inspect = vi.fn(async () => heartbeat);
const deleteSchedule = vi.fn(async () => undefined);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: {
inspect,
delete: deleteSchedule,
} as unknown as ScheduleService,
callerAgentId: "parent-agent",
logger,
});
await registeredTool(server, "delete_heartbeat").handler({ id: heartbeat.id });
expect(deleteSchedule).toHaveBeenCalledWith(heartbeat.id);
});
it("rejects another agent's heartbeat", async () => {
const { agentManager, agentStorage } = createTestDeps();
const foreignHeartbeat = createStoredSchedule({
prompt: "foreign",
cadence: { type: "cron", expression: "0 * * * *" },
target: { type: "agent", agentId: "other-agent" },
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: {
inspect: vi.fn(async () => foreignHeartbeat),
delete: vi.fn(),
} as unknown as ScheduleService,
callerAgentId: "parent-agent",
logger,
});
await expect(
registeredTool(server, "delete_heartbeat").handler({ id: foreignHeartbeat.id }),
).rejects.toThrow("does not belong to caller");
});
});
describe("update_schedule MCP tool", () => {
const logger = createTestLogger();
@@ -4125,6 +4340,16 @@ describe("update_schedule MCP tool", () => {
};
}
function scheduleServiceWithUpdate(
update: (input: UpdateScheduleInput) => Promise<StoredSchedule>,
stored = makeStoredSchedule(),
): ScheduleService {
return {
update,
inspect: vi.fn(async () => stored),
} as unknown as ScheduleService;
}
it("calls scheduleService.update with correct input", async () => {
const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule();
@@ -4138,7 +4363,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4164,7 +4389,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4176,7 +4401,7 @@ describe("update_schedule MCP tool", () => {
expect(update).toHaveBeenCalledWith({
id: "schedule-1",
cadence: { type: "every", everyMs: 600000 },
cadence: { type: "cron", expression: "*/10 * * * *" },
});
});
@@ -4188,7 +4413,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4217,7 +4442,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4230,7 +4455,7 @@ describe("update_schedule MCP tool", () => {
expect(update).toHaveBeenCalledWith({
id: "schedule-1",
cadence: { type: "every", everyMs: 600000 },
cadence: { type: "cron", expression: "*/10 * * * *" },
});
});
@@ -4238,7 +4463,7 @@ describe("update_schedule MCP tool", () => {
{
label: "whitespace cron field",
input: { id: "schedule-1", every: "10m", cron: " " },
cadence: { type: "every", everyMs: 600000 },
cadence: { type: "cron", expression: "*/10 * * * *" },
},
{
label: "blank every field for cron cadence",
@@ -4258,7 +4483,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4278,7 +4503,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4300,7 +4525,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4323,7 +4548,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4347,7 +4572,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4381,7 +4606,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4412,7 +4637,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4455,11 +4680,15 @@ describe("schedule_logs MCP tool", () => {
const { agentManager, agentStorage } = createTestDeps();
const runs = [makeRun({ id: "run-1" }), makeRun({ id: "run-2", status: "failed" })];
const logs = vi.fn(async (_id: string) => runs);
const inspect = vi.fn(async () => ({
id: "schedule-1",
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp" } },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { logs } as unknown as ScheduleService,
scheduleService: { logs, inspect } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "schedule_logs");

File diff suppressed because it is too large Load Diff

View File

@@ -1210,6 +1210,16 @@ export async function createPaseoDaemon(
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
workspaceRegistry,
projectRegistry,
createDirectoryWorkspace: async (cwd, title, projectId) => {
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
cwd,
title,
projectId,
);
await emitWorkspaceUpdatesExternal([workspace.workspaceId]);
return workspace;
},
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
ensureWorkspaceForCreate: createAgentCommandDependencies.ensureWorkspaceForCreate,

View File

@@ -6,6 +6,7 @@ import path from "node:path";
import { DaemonClient } from "./test-utils/index.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
// The daemon-level workspace contract that `paseo run` depends on: each
// local-backed createWorkspace for a cwd mints a fresh, distinct workspace,
@@ -28,9 +29,10 @@ async function mintLocalWorkspace(client: DaemonClient, cwd: string): Promise<st
return result.workspace.id;
}
test("daemon mints a distinct local workspace per run and stamps agents by id", async () => {
test("daemon resolves human and managed CLI workspace ownership", async () => {
const daemon = await createTestPaseoDaemon();
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-cwd-"));
const otherCwd = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-other-cwd-"));
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.82",
@@ -56,14 +58,13 @@ test("daemon mints a distinct local workspace per run and stamps agents by id",
const fetchedFirst = await client.fetchAgent({ agentId: firstAgent.id });
expect(fetchedFirst?.agent.workspaceId).toBe(firstWorkspaceId);
// A second bare run in the SAME cwd mints a DISTINCT workspace; each run
// owns its own workspace rather than reattaching to the first.
const secondWorkspaceId = await mintLocalWorkspace(client, cwd);
// A second bare run mints a distinct workspace with its own authoritative cwd.
const secondWorkspaceId = await mintLocalWorkspace(client, otherCwd);
expect(secondWorkspaceId).not.toBe(firstWorkspaceId);
const secondAgent = await client.createAgent({
...getFullAccessConfig("codex"),
cwd,
cwd: otherCwd,
workspaceId: secondWorkspaceId,
title: "Second run agent",
});
@@ -80,15 +81,48 @@ test("daemon mints a distinct local workspace per run and stamps agents by id",
const idsBeforeAttach = await workspaceIds(client);
const attachedAgent = await client.createAgent({
...getFullAccessConfig("codex"),
cwd,
cwd: path.join(otherCwd, "stale-client-directory"),
workspaceId: firstWorkspaceId,
title: "Attached agent",
});
expect(attachedAgent.workspaceId).toBe(firstWorkspaceId);
expect(attachedAgent.cwd).toBe(cwd);
expect(await workspaceIds(client)).toEqual(idsBeforeAttach);
await expect(
client.createAgent({
...getFullAccessConfig("codex"),
cwd,
workspaceId: "wks_missing",
title: "Missing workspace agent",
}),
).rejects.toThrow("Workspace wks_missing not found");
const sameWorkspaceChild = await client.createAgent({
...getFullAccessConfig("codex"),
cwd: otherCwd,
callerAgentId: firstAgent.id,
title: "Same workspace child",
});
expect(sameWorkspaceChild.workspaceId).toBe(firstWorkspaceId);
expect(sameWorkspaceChild.cwd).toBe(firstAgent.cwd);
expect(sameWorkspaceChild.labels[PARENT_AGENT_ID_LABEL]).toBe(firstAgent.id);
expect(await workspaceIds(client)).toEqual(idsBeforeAttach);
const crossWorkspaceChild = await client.createAgent({
...getFullAccessConfig("codex"),
cwd,
workspaceId: secondWorkspaceId,
callerAgentId: firstAgent.id,
title: "Cross workspace child",
});
expect(crossWorkspaceChild.workspaceId).toBe(secondWorkspaceId);
expect(crossWorkspaceChild.cwd).toBe(otherCwd);
expect(crossWorkspaceChild.labels[PARENT_AGENT_ID_LABEL]).toBe(firstAgent.id);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
rmSync(otherCwd, { recursive: true, force: true });
}
}, 180000);

View File

@@ -57,6 +57,7 @@ test("creates a worktree and registers it in the source workspace project withou
{
cwd: repoDir,
worktreeSlug: "feature-one",
title: "Feature One",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
@@ -70,6 +71,7 @@ test("creates a worktree and registers it in the source workspace project withou
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
expect(result.workspace.displayName).toBe("feature-one");
expect(result.workspace.baseBranch).toBe("main");
expect(result.workspace.title).toBe("Feature One");
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(deps.projects.get(sourceProject.projectId)).toEqual(sourceProject);
expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]);

View File

@@ -31,6 +31,7 @@ import type { FirstAgentContext } from "@getpaseo/protocol/messages";
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
projectId?: string;
title?: string;
}
export interface CreatePaseoWorktreeResult {
@@ -89,7 +90,7 @@ export async function createPaseoWorktree(
worktreeRoot: createdWorktree.worktree.worktreePath,
branch: createdWorktree.worktree.branchName || null,
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext),
});
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });

View File

@@ -87,6 +87,8 @@ export function validateScheduleCadence(cadence: ScheduleCadence): void {
export function computeNextRunAt(cadence: ScheduleCadence, after: Date): Date {
if (cadence.type === "every") {
// COMPAT(scheduleEveryMs): execute legacy persisted rolling intervals until the
// compatibility floor reaches v0.2.0. Added in v0.2.0; remove after 2027-01-17.
return new Date(after.getTime() + cadence.everyMs);
}

View File

@@ -77,6 +77,7 @@ import type {
ManagedAgent,
} from "./agent/agent-manager.js";
import { createAgentCommand } from "./agent/create-agent/create.js";
import { resolveCreateAgentIntent, type CreateAgentIntent } from "./agent/create-agent/intent.js";
import {
archiveAgentCommand,
cancelAgentRunCommand,
@@ -160,6 +161,7 @@ import { PushTokenStore } from "./push/token-store.js";
import {
archivePersistedWorkspaceRecord,
archiveWorkspaceContents,
requireActiveWorkspaceForArchive,
} from "./workspace-archive-service.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import type { ServiceProxySubsystem } from "./service-proxy.js";
@@ -236,6 +238,7 @@ import {
} from "./project-directory-service.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
import { resolveWorktreeSourceCwd } from "./workspace-source.js";
// TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings.
// Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject
@@ -330,6 +333,14 @@ type FetchAgentsResponsePayload = Extract<
type FetchAgentsResponseEntry = FetchAgentsResponsePayload["entries"][number];
type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload["pageInfo"];
type AgentUpdatesFilter = FetchAgentsRequestFilter;
type CreateAgentRequestMessage = Extract<SessionInboundMessage, { type: "create_agent_request" }>;
interface ResolvedSessionCreateAgentIntent {
config: AgentSessionConfig;
intent: CreateAgentIntent;
createdDirectoryWorkspace: boolean;
}
type FetchWorkspacesRequestMessage = Extract<
SessionInboundMessage,
{ type: "fetch_workspaces_request" }
@@ -2778,9 +2789,7 @@ export class Session {
/**
* Handle create agent request
*/
private async handleCreateAgentRequest(
msg: Extract<SessionInboundMessage, { type: "create_agent_request" }>,
): Promise<void> {
private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise<void> {
const {
config,
worktreeName,
@@ -2793,7 +2802,6 @@ export class Session {
autoArchive,
images,
attachments,
labels,
env,
} = msg;
this.sessionLogger.info(
@@ -2807,7 +2815,9 @@ export class Session {
let createdAgentId: string | null = null;
try {
const requestedCwd = resolve(config.cwd);
if (!(await this.filesystem.isDirectory(requestedCwd))) {
const needsRequestedDirectory =
Boolean(worktreeName || git || worktree) || (!msg.workspaceId && !msg.callerAgentId);
if (needsRequestedDirectory && !(await this.filesystem.isDirectory(requestedCwd))) {
throw new Error(`Working directory does not exist or is not a directory: ${requestedCwd}`);
}
const trimmedPrompt = initialPrompt?.trim();
@@ -2828,18 +2838,15 @@ export class Session {
hasLegacyGitOptions: Boolean(git),
});
createdWorktreeForCleanup = createdWorktree;
const createAgentConfig: AgentSessionConfig = createdWorktree
? { ...config, cwd: createdWorktree.workspace.cwd }
: config;
const workspaceId = await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent(
{
createdWorktree,
requestedWorkspaceId: msg.workspaceId,
cwd: createAgentConfig.cwd,
initialTitle: workspacePromptTitle,
},
);
const createdDirectoryWorkspaceForAgent = !createdWorktree && !msg.workspaceId;
const resolvedIntent = await this.resolveSessionCreateAgentIntent({
request: msg,
createdWorktree,
workspacePromptTitle,
});
const resolvedCwd = resolve(resolvedIntent.config.cwd);
if (!(await this.filesystem.isDirectory(resolvedCwd))) {
throw new Error(`Working directory does not exist or is not a directory: ${resolvedCwd}`);
}
const { snapshot, liveSnapshot } = await createAgentCommand(
{
@@ -2852,8 +2859,8 @@ export class Session {
},
{
kind: "session",
config: createAgentConfig,
workspaceId,
config: resolvedIntent.config,
workspaceId: resolvedIntent.intent.workspaceId,
worktreeName,
initialPrompt,
clientMessageId,
@@ -2861,7 +2868,7 @@ export class Session {
images,
attachments,
git,
labels,
labels: resolvedIntent.intent.labels,
env,
provisionalTitle,
firstAgentContext,
@@ -2871,14 +2878,14 @@ export class Session {
);
createdAgentId = snapshot.id;
await this.agentUpdates.forwardLiveAgent(snapshot);
if (createdDirectoryWorkspaceForAgent && trimmedPrompt) {
if (resolvedIntent.createdDirectoryWorkspace && trimmedPrompt) {
this.workspaceAutoName.scheduleForDirectory(
{
workspaceId,
cwd: createAgentConfig.cwd,
workspaceId: resolvedIntent.intent.workspaceId,
cwd: resolvedIntent.config.cwd,
firstAgentContext,
},
{ currentSelection: this.getFocusedAgentSelectionForCwd(createAgentConfig.cwd) },
{ currentSelection: this.getFocusedAgentSelectionForCwd(resolvedIntent.config.cwd) },
);
}
this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({
@@ -2933,6 +2940,55 @@ export class Session {
}
}
private async resolveSessionCreateAgentIntent(input: {
request: CreateAgentRequestMessage;
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
workspacePromptTitle: string | null;
}): Promise<ResolvedSessionCreateAgentIntent> {
const { request, createdWorktree } = input;
const callerAgent = request.callerAgentId
? this.agentManager.getAgent(request.callerAgentId)
: null;
if (request.callerAgentId && !callerAgent) {
throw new Error(`Caller agent ${request.callerAgentId} not found`);
}
let config = request.config;
const intent = await resolveCreateAgentIntent({
explicitWorkspaceId: createdWorktree?.workspace.workspaceId ?? request.workspaceId,
caller: callerAgent
? { id: callerAgent.id, cwd: callerAgent.cwd, workspaceId: callerAgent.workspaceId }
: null,
labels: request.labels,
resolveWorkspace: async (workspaceId) => {
if (createdWorktree?.workspace.workspaceId === workspaceId) {
return { workspaceId, cwd: createdWorktree.workspace.cwd };
}
const workspace = await this.workspaceRegistry.get(workspaceId);
if (!workspace || workspace.archivedAt) {
throw new Error(`Workspace ${workspaceId} not found`);
}
return { workspaceId, cwd: workspace.cwd };
},
createWorkspace: async () => ({
workspaceId: await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent({
createdWorktree: null,
cwd: config.cwd,
initialTitle: input.workspacePromptTitle,
}),
cwd: config.cwd,
}),
});
config = { ...config, cwd: intent.cwd };
return {
config,
intent,
createdDirectoryWorkspace: !createdWorktree && !request.workspaceId && !callerAgent,
};
}
private async handleResumeAgentRequest(
msg: Extract<SessionInboundMessage, { type: "resume_agent_request" }>,
): Promise<void> {
@@ -4874,10 +4930,7 @@ export class Session {
return;
}
const sourceCwd = await this.resolveWorktreeSourceCwd({
cwd: source.cwd,
projectId: source.projectId,
});
const sourceCwd = await resolveWorktreeSourceCwd(source, this.projectRegistry);
const result = await this.createPaseoWorktreeWorkflow(
{
@@ -4886,24 +4939,17 @@ export class Session {
worktreeSlug: source.worktreeSlug,
action: source.action,
refName: source.refName,
branchName: source.branchName,
checkoutSource: source.checkoutSource,
githubPrNumber: source.githubPrNumber,
firstAgentContext: request.firstAgentContext,
title: request.title,
},
source.baseBranch
? { resolveDefaultBranch: async () => source.baseBranch as string }
: undefined,
);
if (request.title?.trim()) {
await this.workspaceRegistry.upsert({
...result.workspace,
title: request.title.trim(),
updatedAt: new Date().toISOString(),
});
result.workspace.title = request.title.trim();
}
const descriptor = await this.describeCreatedWorktreeWorkspace(result);
this.emit({
type: "workspace.create.response",
@@ -4917,20 +4963,6 @@ export class Session {
await this.emitCreatedWorkspaceUpdate(descriptor);
}
private async resolveWorktreeSourceCwd(input: {
cwd?: string;
projectId?: string;
}): Promise<string> {
if (input.cwd) {
return expandTilde(input.cwd);
}
const project = await this.projectRegistry.get(input.projectId as string);
if (!project || project.archivedAt) {
throw new Error(`Project not found: ${input.projectId}`);
}
return project.rootPath;
}
private async handleOpenProjectRequest(
request: Extract<SessionInboundMessage, { type: "open_project_request" }>,
): Promise<void> {
@@ -5399,10 +5431,10 @@ export class Session {
request: Extract<SessionInboundMessage, { type: "archive_workspace_request" }>,
): Promise<void> {
try {
const existing = await this.workspaceRegistry.get(request.workspaceId);
if (!existing) {
throw new Error(`Workspace not found: ${request.workspaceId}`);
}
const existing = await requireActiveWorkspaceForArchive(
{ listActiveWorkspaces: () => this.listActiveWorkspaceRefs() },
request.workspaceId,
);
await archiveByScope(
{

View File

@@ -67,6 +67,19 @@ export interface ArchiveByScopeRequest {
requestId: string;
}
export async function requireActiveWorkspaceForArchive(
dependencies: Pick<ArchiveDependencies, "listActiveWorkspaces">,
workspaceId: string,
): Promise<ActiveWorkspaceRef> {
const workspace = (await dependencies.listActiveWorkspaces()).find(
(candidate) => candidate.workspaceId === workspaceId,
);
if (!workspace) {
throw new Error(`Workspace not found: ${workspaceId}`);
}
return workspace;
}
interface BackingDirectory {
path: string;
isPaseoOwnedWorktree: boolean;

View File

@@ -8,7 +8,7 @@ import { DaemonClient } from "./test-utils/index.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
// The reshaped workspace.create.request forwards its worktree `source`
// (action/refName/githubPrNumber/worktreeSlug) verbatim into createWorktreeCore.
// (action/refName/branchName/githubPrNumber/worktreeSlug) into createWorktreeCore.
// The regression these tests guard against is the daemon dropping action/refName
// while subsetting the request. We prove forwarding through the real workflow:
// the created worktree's observable branch is the only honest evidence the
@@ -64,7 +64,7 @@ test("workspace.create worktree source forwards action=checkout + refName into t
}
}, 180000);
test("workspace.create worktree source forwards branch-off + refName as the new branch", async () => {
test("workspace.create keeps a branch-off name separate from its worktree slug", async () => {
const daemon = await createTestPaseoDaemon();
const { repoDir, tempRoot } = createGitRepoWithBranch();
const client = new DaemonClient({
@@ -80,15 +80,15 @@ test("workspace.create worktree source forwards branch-off + refName as the new
kind: "worktree",
cwd: repoDir,
action: "branch-off",
worktreeSlug: "brand-new-branch",
branchName: "feature/auth",
worktreeSlug: "feature-auth",
baseBranch: "main",
},
});
expect(result.error).toBeNull();
// branch-off cuts a new branch named after worktreeSlug from baseBranch; the
// worktree landing on that branch proves worktreeSlug/baseBranch forwarded.
expect(result.workspace?.gitRuntime?.currentBranch).toBe("brand-new-branch");
expect(result.workspace?.gitRuntime?.currentBranch).toBe("feature/auth");
expect(path.basename(result.workspace?.workspaceDirectory ?? "")).toBe("feature-auth");
} finally {
await client.close().catch(() => undefined);
await daemon.close();

View File

@@ -0,0 +1,25 @@
import { expandTilde } from "../utils/path.js";
import type { ProjectRegistry } from "./workspace-registry.js";
export interface WorktreeWorkspaceSource {
cwd?: string;
projectId?: string;
}
export async function resolveWorktreeSourceCwd(
source: WorktreeWorkspaceSource,
projectRegistry: Pick<ProjectRegistry, "get">,
): Promise<string> {
if (source.cwd) {
return expandTilde(source.cwd);
}
if (!source.projectId) {
throw new Error("cwd or projectId is required for a worktree-backed workspace");
}
const project = await projectRegistry.get(source.projectId);
if (!project || project.archivedAt) {
throw new Error(`Project not found: ${source.projectId}`);
}
return project.rootPath;
}

View File

@@ -17,7 +17,7 @@ Read the **paseo** skill. Before choosing a provider, read `~/.paseo/orchestrati
## Parsing arguments
1. **Provider** — explicit user request first; otherwise resolve from `impl` preference (or `ui` if the task is styling-only).
2. **Worktree** — "in a worktree" / "worktree" → create a worktree via Paseo with a short branch name derived from the task, based on the current branch.
2. **Isolation** — "in a worktree" / "worktree" → create a workspace with `isolation: "worktree"`, using a short branch name derived from the task.
3. **Task description** — anything else the user said.
## The handoff prompt
@@ -54,18 +54,14 @@ The receiving agent has zero context. Include:
## Launch
Create the agent via Paseo with a `[Handoff] <task>` title, the briefing as initial prompt, and `relationship: { kind: "detached" }`.
Prepare the handoff in a dedicated workspace:
Use `workspace` for placement:
1. Select the current workspace or call `create_workspace` with the requested isolation.
2. Call `create_agent` with a `[Handoff] <task>` title, the briefing as initial prompt, and the selected `workspaceId` when explicit placement is needed.
3. Return the agent and workspace to the user, explaining that it remains in your subagent track until they detach it manually.
- No worktree: `workspace: { kind: "current" }`.
- Worktree: `workspace: { kind: "create", source: { kind: "worktree", target: { kind: "branch-off", worktreeSlug: "<short-task-slug>", branchName: "fix/<short-task-slug>" } } }`.
- Existing worktree already created by `create_worktree`: `workspace: { kind: "existing", workspaceId: "<returned-workspace-id>" }`.
Do not use `workspace: { kind: "current", cwd: "<worktreePath>" }` to place a handoff in a worktree; that keeps the agent in the caller's workspace with only a different runtime cwd.
Do not encode independence as a create mode and do not invoke CLI or wire-level detach operations. Detach is a user gesture in the subagents track.
Leave `notifyOnFinish` omitted unless the user explicitly wants no callback.
Handoff agents are siblings/root agents, not your subagents. They must survive you being archived and must not appear in your subagent track.
Don't wait by default — the user decides whether to follow along or move on. Tell them the agent ID and how to follow along (the paseo skill explains).

View File

@@ -8,6 +8,8 @@ user-invocable: true
A loop is a worker/verifier cycle: launch a worker → check verification → repeat until done or limits hit. Use for "keep trying", "babysit", or "watch this until X."
For lightweight recurring checks that should return to this same conversation, prefer a cron heartbeat (`create_heartbeat`, then `delete_heartbeat` when finished). To change it, delete and recreate it. Use a loop when each iteration needs a worker/verifier lifecycle; use a schedule when each firing should create a fresh independent agent and workspace.
**User's arguments:** $ARGUMENTS
## Prerequisites

View File

@@ -1,48 +1,29 @@
---
name: paseo
description: Paseo reference for managing agents and worktrees. Load whenever you need to create agents, send them prompts, or manage worktrees.
description: Paseo reference for managing workspaces, agents, schedules, and heartbeats.
---
Paseo is a daemon that supervises AI coding agents on your machine. Control it through tools or a CLI.
## Worktrees
## Workspaces
**`create_worktree`** — same target union as `create_agent.workspace.source.worktree.target`:
**`create_workspace`** — create a workspace independently of any agent. Required: `isolation` (`local` or `worktree`). Worktree isolation supports `mode: "branch-off" | "checkout-branch" | "checkout-pr"`: use `branchName`/`baseBranch` for a new branch, `branch` for an existing branch, or `prNumber` plus optional `forge`/`projectPath` for a change request. `worktreeSlug` controls the managed path. Returns the workspace descriptor centered on `workspaceId`.
- From a PR: `{ target: { kind: "checkout-pr", githubPrNumber: 503 } }`.
- Branch off a base: `{ target: { kind: "branch-off", worktreeSlug: "foo", branchName: "fix/foo", baseBranch: "main" } }`.
- Checkout an existing branch: `{ target: { kind: "checkout-branch", branch: "feat/bar" } }`.
**`list_workspaces`** — list active workspaces.
Returns `{ branchName, worktreePath, workspaceId }`. Pass `cwd` to target a specific repo.
**`archive_workspace`** — `{ workspaceId }`. Archives the workspace, its agents, and its terminals. Local directories remain; Paseo removes an owned worktree only after its final active workspace reference is archived.
In `branch-off`, `worktreeSlug` controls the worktree path slug and `branchName` controls the git branch. If `branchName` is omitted, Paseo defaults it from `worktreeSlug`. The returned `branchName` is authoritative; checkout and PR flows may return a branch name that differs from any requested slug.
**`list_worktrees`** — current repo (or pass `cwd`).
**`archive_worktree`** — `{ worktreePath }` or `{ worktreeSlug }`. Removes worktree and branch.
Worktree creation and reference accounting are implementation details of `isolation: "worktree"`.
## Agents
**`create_agent`** — required: `relationship`, `workspace`, `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `notifyOnFinish`, `settings`, `labels`. Returns `{ agentId, … }`.
**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Optional: `workspaceId`, `notifyOnFinish`, `settings`, `labels`. Returns `{ agentId, workspaceId, … }`.
Initial runtime settings live under `settings`: `modeId`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }` when creating the agent.
To create a new worktree and launch an agent in it, use `create_agent.workspace.source.kind = "worktree"`. Use `create_worktree` separately only when you need a worktree without launching an agent, or when you need a split flow; in a split flow, pass the returned `workspaceId` to `create_agent` with `workspace: { kind: "existing", workspaceId }`.
Agent-scoped creation always creates your subagent. Omit `workspaceId` to use your current workspace; pass a workspace returned by `create_workspace` for isolated delegation. Placement never changes parentage.
### Agent relationships
`relationship` controls parentage only:
- `{ kind: "subagent" }` — child under your subagents track. Use for advisors, committee members, planners, implementers, auditors, loop workers, and any agent whose lifetime belongs to your task.
- `{ kind: "detached" }` — root/sibling agent. Use for handoffs and fire-and-forget delegations the user may continue after you are archived.
`workspace` controls placement only:
- `{ kind: "current" }` — same workspace as the caller, with optional `cwd`.
- `{ kind: "existing", workspaceId: string, cwd?: string }` — attach to an existing workspace, usually from `create_worktree`.
- `{ kind: "create", source: { kind: "directory", path?: string } }` — new workspace rooted at a directory.
- `{ kind: "create", source: { kind: "worktree", cwd?: string, target: { kind: "branch-off", worktreeSlug?: string, branchName?: string, baseBranch?: string } } }`
- `{ kind: "create", source: { kind: "worktree", cwd?: string, target: { kind: "checkout-branch", branch: string } } }`
- `{ kind: "create", source: { kind: "worktree", cwd?: string, target: { kind: "checkout-pr", githubPrNumber: number } } }`
Detach is an explicit user action in the subagents track, not an agent tool. A cross-workspace child remains your subagent even though it also appears as a normal tab in its workspace.
Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` only for truly fire-and-forget agents.
@@ -70,6 +51,10 @@ Only set feature IDs returned by `inspect_provider`. For Codex fast mode, look f
**`create_heartbeat`** — sends you a prompt on a cron cadence. Required: `prompt`, `cron`. Optional: `timezone`, `name`, `maxRuns`, `expiresIn`. Use for reminders, PR/build babysitting, and status checks that should return to this conversation.
**`delete_heartbeat`** stops it. MCP intentionally exposes no heartbeat update tool; delete and recreate when its task or cadence changes.
Schedules have the full list/inspect/update/pause/resume/run-once/log/delete surface. Heartbeats deliberately do not.
## Models
`claude/sonnet` (default), `claude/opus` (harder reasoning), `codex/gpt-5.4` (frontier coding), `claude/haiku` (tests only).
@@ -110,16 +95,19 @@ For agent-scoped `create_agent` and background `send_agent_prompt`, leave `notif
Don't poll `list_agents` or `get_agent_status` to "check on" a running agent. The notification will tell you.
## CLI parity
## CLI semantics
The `paseo` CLI is a thin wrapper over the same daemon. Same surface:
The CLI and tools use the same ownership semantics even where their syntax differs:
```bash
paseo run --provider codex/gpt-5.4 --mode full-access --worktree feat/x "<prompt>"
paseo workspace create --isolation worktree --mode branch-off --new-branch fix-x --base main
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 send <agent-id> "<follow-up>"
paseo ls
paseo worktree ls
paseo schedule create --cron "*/15 * * * *" "ping main build"
paseo heartbeat create --cron "*/15 * * * *" "check the build"
```
Discover with `paseo --help` and `paseo <cmd> --help`.