diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index b87b700ff..2be3c3b55 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 9d9af4bb7..b4ab8ef21 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/data-model.md b/docs/data-model.md index b2ff26ba4..0d34d49a6 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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` | 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` | 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`) diff --git a/docs/development.md b/docs/development.md index d303f3c22..c36de231f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 diff --git a/docs/glossary.md b/docs/glossary.md index fc8878f74..e2345e704 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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. diff --git a/docs/product.md b/docs/product.md index fbfc4cfa0..2eeeb0b84 100644 --- a/docs/product.md +++ b/docs/product.md @@ -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 diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index 721ceb225..febfbf6ca 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -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 | undefined, +): Extract { + 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 => { - 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 => { 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( - () => ({ title: mode === "edit" ? "Edit schedule" : "New schedule" }), - [mode], - ); + const header = useMemo(() => { + 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 ( + <> + + + {state.submitError ? {state.submitError} : null} + + ); + } + return ( <> @@ -502,7 +525,7 @@ function ScheduleFormFields({ model={model} state={state} providerSnapshot={providerSnapshot} - agentTargetLabel={agentTargetLabel} + agentTargetLabel={null} controlSize={controlSize} mutationServerId={mutationServerId} /> diff --git a/packages/app/src/components/schedules/schedule-row.tsx b/packages/app/src/components/schedules/schedule-row.tsx index a5b6b0c4e..faf491a35 100644 --- a/packages/app/src/components/schedules/schedule-row.tsx +++ b/packages/app/src/components/schedules/schedule-row.tsx @@ -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}`} > @@ -222,6 +228,66 @@ const resumeLeading = ; const deleteLeading = ; +function ScheduleExecutionMenuItems({ + schedule, + canRun, + pending, + onPause, + onResume, + onRunNow, +}: Pick & { + canRun: boolean; +}): ReactElement | null { + if (schedule.target.type === "agent") { + return null; + } + + let cadenceAction: ReactElement; + if (schedule.status === "paused") { + cadenceAction = ( + + Resume schedule + + ); + } else { + cadenceAction = ( + + Pause schedule + + ); + } + + return ( + <> + {cadenceAction} + + Run now + + + ); +} + function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElement { return ( & { canRun: boolean; }): ReactElement { + const productName = scheduleProductName(schedule); + const productNameLower = productName.toLowerCase(); return ( {renderKebabTriggerIcon} @@ -263,41 +331,16 @@ function ScheduleKebabMenu({ onSelect={onEdit} testID={`schedule-menu-edit-${schedule.id}`} > - Edit schedule - - {schedule.status === "paused" ? ( - - Resume schedule - - ) : ( - - Pause schedule - - )} - - Run now + Edit {productNameLower} + - Delete schedule + Delete {productNameLower} diff --git a/packages/app/src/components/schedules/schedules-table.tsx b/packages/app/src/components/schedules/schedules-table.tsx index 037bc1f10..c591b03d4 100644 --- a/packages/app/src/components/schedules/schedules-table.tsx +++ b/packages/app/src/components/schedules/schedules-table.tsx @@ -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, diff --git a/packages/app/src/schedules/schedule-form-model.test.ts b/packages/app/src/schedules/schedule-form-model.test.ts index ef490e857..5a50cd33a 100644 --- a/packages/app/src/schedules/schedule-form-model.test.ts +++ b/packages/app/src/schedules/schedule-form-model.test.ts @@ -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", diff --git a/packages/app/src/schedules/schedule-form-model.ts b/packages/app/src/schedules/schedule-form-model.ts index 39d4b66b3..33928ea72 100644 --- a/packages/app/src/schedules/schedule-form-model.ts +++ b/packages/app/src/schedules/schedule-form-model.ts @@ -77,6 +77,7 @@ export interface ScheduleFormProjectOption { } export type ScheduleFormTargetKind = "agent" | "new-agent"; +type CronCadence = Extract; 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, diff --git a/packages/app/src/utils/schedule-format.test.ts b/packages/app/src/utils/schedule-format.test.ts index 8715aa03c..529c3d913 100644 --- a/packages/app/src/utils/schedule-format.test.ts +++ b/packages/app/src/utils/schedule-format.test.ts @@ -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" })), diff --git a/packages/app/src/utils/schedule-format.ts b/packages/app/src/utils/schedule-format.ts index 2fa21ce4e..8a239768f 100644 --- a/packages/app/src/utils/schedule-format.ts +++ b/packages/app/src/utils/schedule-format.ts @@ -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 { diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts new file mode 100644 index 000000000..fa772f85c --- /dev/null +++ b/packages/cli/src/cli-surface.test.ts @@ -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 "); + expect(run?.helpInformation()).not.toContain("--worktree "); + }); + + 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"); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 318252802..9124fc6c3 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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; } diff --git a/packages/cli/src/commands/agent/detach.ts b/packages/cli/src/commands/agent/detach.ts new file mode 100644 index 000000000..5de8ae8c9 --- /dev/null +++ b/packages/cli/src/commands/agent/detach.ts @@ -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 = { + idField: "agentId", + columns: [ + { header: "AGENT ID", field: "agentId" }, + { header: "STATUS", field: "status" }, + ], +}; + +export async function runDetachCommand( + agentIdArg: string, + options: CommandOptions, + _command: Command, +): Promise> { + 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); + } +} diff --git a/packages/cli/src/commands/agent/index.ts b/packages/cli/src/commands/agent/index.ts index 9c592d2ae..37d8f6949 100644 --- a/packages/cli/src/commands/agent/index.ts +++ b/packages/cli/src/commands/agent/index.ts @@ -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("", "Agent ID, prefix, or name"), + ).action(withOutput(runDetachCommand)); + addJsonAndDaemonHostOptions( agent .command("update") diff --git a/packages/cli/src/commands/agent/run.test.ts b/packages/cli/src/commands/agent/run.test.ts index 65ea370ec..59081c99d 100644 --- a/packages/cli/src/commands/agent/run.test.ts +++ b/packages/cli/src/commands/agent/run.test.ts @@ -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/); + }); }); diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index 60f30abc3..d283a6d10 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -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("", "The task/prompt for the agent") - .option("-d, --detach", "Run in background (detached)") - .option("--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; +} diff --git a/packages/cli/src/commands/heartbeat/index.ts b/packages/cli/src/commands/heartbeat/index.ts new file mode 100644 index 000000000..7ded358e8 --- /dev/null +++ b/packages/cli/src/commands/heartbeat/index.ts @@ -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; +} diff --git a/packages/cli/src/commands/schedule/index.ts b/packages/cli/src/commands/schedule/index.ts index 5087db18a..f72a59b19 100644 --- a/packages/cli/src/commands/schedule/index.ts +++ b/packages/cli/src/commands/schedule/index.ts @@ -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)") diff --git a/packages/cli/src/commands/schedule/inspect.ts b/packages/cli/src/commands/schedule/inspect.ts index d9044382f..d020f2408 100644 --- a/packages/cli/src/commands/schedule/inspect.ts +++ b/packages/cli/src/commands/schedule/inspect.ts @@ -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", diff --git a/packages/cli/src/commands/schedule/logs.ts b/packages/cli/src/commands/schedule/logs.ts index b411822d9..c9565b5c0 100644 --- a/packages/cli/src/commands/schedule/logs.ts +++ b/packages/cli/src/commands/schedule/logs.ts @@ -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); diff --git a/packages/cli/src/commands/schedule/ls.ts b/packages/cli/src/commands/schedule/ls.ts index da2c0dfbc..501c86653 100644 --- a/packages/cli/src/commands/schedule/ls.ts +++ b/packages/cli/src/commands/schedule/ls.ts @@ -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) { diff --git a/packages/cli/src/commands/schedule/pause.ts b/packages/cli/src/commands/schedule/pause.ts index 97e5914a5..ed8e12108 100644 --- a/packages/cli/src/commands/schedule/pause.ts +++ b/packages/cli/src/commands/schedule/pause.ts @@ -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}`); diff --git a/packages/cli/src/commands/schedule/resume.ts b/packages/cli/src/commands/schedule/resume.ts index 88d4853dc..efac2d634 100644 --- a/packages/cli/src/commands/schedule/resume.ts +++ b/packages/cli/src/commands/schedule/resume.ts @@ -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}`); diff --git a/packages/cli/src/commands/schedule/run-once.ts b/packages/cli/src/commands/schedule/run-once.ts index 705e9548f..2818afc5d 100644 --- a/packages/cli/src/commands/schedule/run-once.ts +++ b/packages/cli/src/commands/schedule/run-once.ts @@ -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}`); diff --git a/packages/cli/src/commands/schedule/shared.test.ts b/packages/cli/src/commands/schedule/shared.test.ts index e483e71aa..d1684e7ac 100644 --- a/packages/cli/src/commands/schedule/shared.test.ts +++ b/packages/cli/src/commands/schedule/shared.test.ts @@ -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"); + }); +}); diff --git a/packages/cli/src/commands/schedule/shared.ts b/packages/cli/src/commands/schedule/shared.ts index 643037c06..f70677db4 100644 --- a/packages/cli/src/commands/schedule/shared.ts +++ b/packages/cli/src/commands/schedule/shared.ts @@ -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; diff --git a/packages/cli/src/commands/schedule/update.ts b/packages/cli/src/commands/schedule/update.ts index 5b62590fd..30873853c 100644 --- a/packages/cli/src/commands/schedule/update.ts +++ b/packages/cli/src/commands/schedule/update.ts @@ -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}`); diff --git a/packages/cli/src/commands/workspace/archive.ts b/packages/cli/src/commands/workspace/archive.ts new file mode 100644 index 000000000..958d0451d --- /dev/null +++ b/packages/cli/src/commands/workspace/archive.ts @@ -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); + } +} diff --git a/packages/cli/src/commands/workspace/create.test.ts b/packages/cli/src/commands/workspace/create.test.ts new file mode 100644 index 000000000..62bfcdc6f --- /dev/null +++ b/packages/cli/src/commands/workspace/create.test.ts @@ -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", + ); + }); +}); diff --git a/packages/cli/src/commands/workspace/create.ts b/packages/cli/src/commands/workspace/create.ts new file mode 100644 index 000000000..078eefb06 --- /dev/null +++ b/packages/cli/src/commands/workspace/create.ts @@ -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); + } +} diff --git a/packages/cli/src/commands/workspace/index.ts b/packages/cli/src/commands/workspace/index.ts new file mode 100644 index 000000000..27d7131dc --- /dev/null +++ b/packages/cli/src/commands/workspace/index.ts @@ -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; +} diff --git a/packages/cli/src/commands/workspace/ls.ts b/packages/cli/src/commands/workspace/ls.ts new file mode 100644 index 000000000..7ea9e8f56 --- /dev/null +++ b/packages/cli/src/commands/workspace/ls.ts @@ -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); + } +} diff --git a/packages/cli/src/commands/workspace/shared.ts b/packages/cli/src/commands/workspace/shared.ts new file mode 100644 index 000000000..cd800c9f1 --- /dev/null +++ b/packages/cli/src/commands/workspace/shared.ts @@ -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, + }; +} diff --git a/packages/cli/src/utils/duration.ts b/packages/cli/src/utils/duration.ts index 1041917fd..0fe1dc274 100644 --- a/packages/cli/src/utils/duration.ts +++ b/packages/cli/src/utils/duration.ts @@ -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; } diff --git a/packages/cli/tests/05-agent-run.test.ts b/packages/cli/tests/05-agent-run.test.ts index 5b1fb97c4..1c2700a50 100644 --- a/packages/cli/tests/05-agent-run.test.ts +++ b/packages/cli/tests/05-agent-run.test.ts @@ -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 diff --git a/packages/cli/tests/14-worktree.test.ts b/packages/cli/tests/14-worktree.test.ts index 6e9289326..d1b30a541 100644 --- a/packages/cli/tests/14-worktree.test.ts +++ b/packages/cli/tests/14-worktree.test.ts @@ -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 diff --git a/packages/cli/tests/31-loop-schedule.test.ts b/packages/cli/tests/31-loop-schedule.test.ts index f5513bdcd..ae070f8fc 100644 --- a/packages/cli/tests/31-loop-schedule.test.ts +++ b/packages/cli/tests/31-loop-schedule.test.ts @@ -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( diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 37a766d23..b1d6bfcf9 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -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", }), ); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 6eaa0b155..c0ecc10c4 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -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 } : {}), diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index fe9cc1998..d7f21f124 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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>; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 48e9f106d..f1338bd45 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -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. diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index 59c772874..a1c07a5ca 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -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", diff --git a/packages/protocol/src/schedule/cadence.test.ts b/packages/protocol/src/schedule/cadence.test.ts new file mode 100644 index 000000000..3fd3a4af5 --- /dev/null +++ b/packages/protocol/src/schedule/cadence.test.ts @@ -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(); + }, + ); +}); diff --git a/packages/protocol/src/schedule/cadence.ts b/packages/protocol/src/schedule/cadence.ts new file mode 100644 index 000000000..57abb7e7d --- /dev/null +++ b/packages/protocol/src/schedule/cadence.ts @@ -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; +} diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index d726509a8..f42061698 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -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({ diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 325fa0dcb..96b4c9c29 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -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({ diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 539f324d2..182edc833 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -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; -} diff --git a/packages/server/src/server/agent/create-agent/intent.test.ts b/packages/server/src/server/agent/create-agent/intent.test.ts new file mode 100644 index 000000000..38f390d3f --- /dev/null +++ b/packages/server/src/server/agent/create-agent/intent.test.ts @@ -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: {}, + }); + }); +}); diff --git a/packages/server/src/server/agent/create-agent/intent.ts b/packages/server/src/server/agent/create-agent/intent.ts new file mode 100644 index 000000000..c811144b7 --- /dev/null +++ b/packages/server/src/server/agent/create-agent/intent.ts @@ -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(); +} diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 66f71f829..532389815 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -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"); diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 0cc428193..12aa43079 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -23,12 +23,15 @@ import type { AgentStorage } from "../agent-storage.js"; import { ensureAgentLoaded } from "../agent-loading.js"; import { isStoredAgentProviderAvailable } from "../../persistence-hooks.js"; import { + archiveByScope, killTerminalsForWorkspace, + requireActiveWorkspaceForArchive, type ArchiveDependencies, } from "../../workspace-archive-service.js"; import { createAgentCommand, type CreateAgentFromMcpInput } from "../create-agent/create.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js"; import type { FirstAgentContext } from "../../messages.js"; +import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence"; import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js"; @@ -65,14 +68,16 @@ import { } from "../lifecycle-command.js"; import type { ForgeService } from "../../../services/forge-service.js"; import type { WorkspaceGitService } from "../../workspace-git-service.js"; -import type { WorkspaceRegistry } from "../../workspace-registry.js"; -import { WorktreeRequestError } from "../../worktree-errors.js"; +import type { + PersistedWorkspaceRecord, + ProjectRegistry, + WorkspaceRegistry, +} from "../../workspace-registry.js"; +import { resolveWorktreeSourceCwd } from "../../workspace-source.js"; import { - archiveCommand, type ArchiveCommandDependencies, - createPaseoWorktreeCommand, type CreatePaseoWorktreeCommandInput, - listPaseoWorktreesCommand, + createPaseoWorktreeCommand, } from "../../worktree/commands.js"; import { registerBrowserTools } from "../../browser-tools/tools.js"; import type { BrowserToolsBroker } from "../../browser-tools/broker.js"; @@ -100,7 +105,13 @@ export interface PaseoToolHostDependencies { listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"]; archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"]; emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; - workspaceRegistry?: Pick<WorkspaceRegistry, "get" | "upsert">; + workspaceRegistry?: Pick<WorkspaceRegistry, "get" | "list" | "upsert">; + projectRegistry?: Pick<ProjectRegistry, "get">; + createDirectoryWorkspace?: ( + cwd: string, + title?: string | null, + projectId?: string, + ) => Promise<PersistedWorkspaceRecord>; markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"]; clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"]; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; @@ -157,6 +168,105 @@ interface ProviderSummary { error?: string; } +const WorkspaceAutomationSummarySchema = z.object({ + workspaceId: z.string(), + projectId: z.string(), + cwd: z.string(), + isolation: z.enum(["local", "worktree"]), + kind: z.enum(["directory", "local_checkout", "worktree"]), + title: z.string().nullable(), +}); + +function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) { + return { + workspaceId: workspace.workspaceId, + projectId: workspace.projectId, + cwd: workspace.cwd, + isolation: workspace.kind === "worktree" ? ("worktree" as const) : ("local" as const), + kind: workspace.kind, + title: workspace.title, + }; +} + +type WorkspaceWorktreeMode = "branch-off" | "checkout-branch" | "checkout-pr"; + +interface WorkspaceWorktreeOptions { + mode?: WorkspaceWorktreeMode; + worktreeSlug?: string; + branchName?: string; + baseBranch?: string; + branch?: string; + prNumber?: number; + forge?: string; +} + +type WorkspaceWorktreeTarget = Pick< + CreatePaseoWorktreeCommandInput, + "action" | "branchName" | "refName" | "checkoutSource" +>; + +function assertOptionsAbsent( + options: Array<[name: string, value: unknown]>, + message: string, +): void { + if (options.some(([, value]) => value !== undefined)) { + throw new Error(message); + } +} + +function resolveWorkspaceWorktreeTarget(input: WorkspaceWorktreeOptions): WorkspaceWorktreeTarget { + switch (input.mode ?? "branch-off") { + case "branch-off": + assertOptionsAbsent( + [ + ["branch", input.branch], + ["prNumber", input.prNumber], + ["forge", input.forge], + ], + "branch, prNumber, and forge require a checkout mode", + ); + return { + action: "branch-off", + ...(input.branchName ? { branchName: input.branchName } : {}), + ...(input.baseBranch ? { refName: input.baseBranch } : {}), + }; + case "checkout-branch": + if (!input.branch) { + throw new Error("branch is required for checkout-branch mode"); + } + assertOptionsAbsent( + [ + ["branchName", input.branchName], + ["baseBranch", input.baseBranch], + ["prNumber", input.prNumber], + ["forge", input.forge], + ], + "branchName, baseBranch, prNumber, and forge are not valid for checkout-branch mode", + ); + return { action: "checkout", refName: input.branch }; + case "checkout-pr": + if (input.prNumber === undefined) { + throw new Error("prNumber is required for checkout-pr mode"); + } + assertOptionsAbsent( + [ + ["branchName", input.branchName], + ["baseBranch", input.baseBranch], + ["branch", input.branch], + ], + "branchName, baseBranch, and branch are not valid for checkout-pr mode", + ); + return { + action: "checkout", + checkoutSource: { + kind: "change_request", + ...(input.forge ? { forge: input.forge } : {}), + number: input.prNumber, + }, + }; + } +} + function toProviderSummary(entry: { provider: AgentProvider; label?: string; @@ -307,7 +417,14 @@ function resolveScheduleUpdateCadence(input: ScheduleUpdateToolInput): ScheduleC throw new Error("timezone can only be used with cron"); } if (every !== undefined) { - return { type: "every", everyMs: parseDurationString(every) }; + // COMPAT(scheduleEveryInput): accept the old hidden field and canonicalize it before write. + // Added in v0.2.0; remove after 2027-01-17. + const everyMs = parseDurationString(every); + const expression = everyMsToFiveFieldCron(everyMs); + if (expression) { + return { type: "cron", expression }; + } + throw new Error(`${every} cannot be represented faithfully by five-field cron`); } if (cron !== undefined) { return { @@ -382,13 +499,6 @@ const TerminalSummarySchema = z.object({ cwd: z.string(), }); -const WorktreeSummarySchema = z.object({ - path: z.string(), - createdAt: z.string(), - branchName: z.string().optional(), - head: z.string().optional(), -}); - function resolveTerminalKeyToken(key: string, literal: boolean): string { if (literal) { return key; @@ -592,7 +702,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ...(typeof callerAgent.config.webSearch === "boolean" ? { webSearch: callerAgent.config.webSearch } : {}), - ...(callerAgent.config.title ? { title: callerAgent.config.title } : {}), ...(callerAgent.config.extra ? { extra: callerAgent.config.extra } : {}), ...(callerAgent.config.featureValues ? { featureValues: callerAgent.config.featureValues } @@ -633,19 +742,26 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; }; - const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => { - if (!params?.provider?.trim()) { - throw new Error("provider is required when target is new-agent"); - } - + const resolveNewAgentScheduleTarget = (params?: { + provider?: string; + cwd?: string; + isolation?: "local" | "worktree"; + }) => { const callerAgent = resolveCallerAgent(); if (callerAgent) { return { type: "new-agent" as const, - config: buildCallerAgentScheduleConfig(callerAgent, params), + config: { + ...buildCallerAgentScheduleConfig(callerAgent, params), + ...(params?.isolation ? { isolation: params.isolation } : {}), + }, }; } + if (!params?.provider?.trim()) { + throw new Error("provider is required when target is new-agent"); + } + const resolvedProviderModel = resolveScheduleProviderAndModel({ provider: params?.provider, defaultProvider: params.provider, @@ -656,9 +772,34 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase provider: resolvedProviderModel.provider, cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(), ...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}), + ...(params?.isolation ? { isolation: params.isolation } : {}), }, }; }; + + async function requireScheduleTarget(id: string, type: "agent" | "new-agent") { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + const schedule = await scheduleService.inspect(id); + if (schedule.target.type !== type) { + throw new Error( + type === "agent" ? `Heartbeat not found: ${id}` : `Schedule not found: ${id}`, + ); + } + return schedule; + } + + async function requireCallerHeartbeat(id: string) { + if (!callerAgentId) { + throw new Error("Heartbeat operations require an agent-scoped session"); + } + const schedule = await requireScheduleTarget(id, "agent"); + if (schedule.target.type !== "agent" || schedule.target.agentId !== callerAgentId) { + throw new Error(`Heartbeat ${id} does not belong to caller ${callerAgentId}`); + } + return schedule; + } const ProviderModelInputSchema = AgentProviderEnum.trim() .refine((value) => value.includes("/"), { message: "provider must be provider/model, for example codex/gpt-5.4", @@ -820,13 +961,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase .strict() .describe("Create a new workspace for the agent."), ]); - const commonCreateAgentInputSchema = { - relationship: AgentRelationshipInputSchema.describe( - "Whether the created agent is a subagent under you or a detached root agent.", - ), - workspace: AgentWorkspaceInputSchema.describe( - "Workspace ownership/location for the created agent.", - ), + const commonCreateAgentFields = { title: z .string() .trim() @@ -846,8 +981,26 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase .min(1, "initialPrompt is required") .describe("Required first task to run immediately after creation."), }; + const legacyCreateAgentPlacementFields = { + relationship: AgentRelationshipInputSchema.describe( + "Whether the created agent is a subagent under you or a detached root agent.", + ), + workspace: AgentWorkspaceInputSchema.describe( + "Workspace ownership/location for the created agent.", + ), + }; + const canonicalCreateAgentFields = { + ...commonCreateAgentFields, + workspaceId: z + .string() + .min(1) + .optional() + .describe( + "Existing workspace id. Agent-scoped calls default to the caller workspace; top-level calls create a new local workspace when omitted.", + ), + }; const agentToAgentInputSchema = { - ...commonCreateAgentInputSchema, + ...canonicalCreateAgentFields, notifyOnFinish: z .boolean() .optional() @@ -857,7 +1010,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ), }; const canonicalTopLevelInputSchema = { - ...commonCreateAgentInputSchema, + ...canonicalCreateAgentFields, background: z .boolean() .optional() @@ -873,9 +1026,17 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase "Agent-scoped only: get notified when the created agent finishes, errors, or needs permission.", ), }; + const legacyAgentToAgentInputSchema = { + ...commonCreateAgentFields, + ...legacyCreateAgentPlacementFields, + notifyOnFinish: agentToAgentInputSchema.notifyOnFinish, + }; const legacyTopLevelCreateAgentInputSchema = { - relationship: commonCreateAgentInputSchema.relationship.optional(), - workspace: commonCreateAgentInputSchema.workspace.optional(), + ...commonCreateAgentFields, + relationship: legacyCreateAgentPlacementFields.relationship.optional(), + workspace: legacyCreateAgentPlacementFields.workspace.optional(), + background: canonicalTopLevelInputSchema.background, + notifyOnFinish: canonicalTopLevelInputSchema.notifyOnFinish, cwd: z .string() .optional() @@ -916,15 +1077,15 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase .optional() .describe("Legacy GitHub PR number. Prefer workspace.source.target.githubPrNumber."), }; - const topLevelInputSchema = { - ...canonicalTopLevelInputSchema, - ...legacyTopLevelCreateAgentInputSchema, - }; - - const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema; + const createAgentInputSchema = z + .object(callerAgentId ? agentToAgentInputSchema : canonicalTopLevelInputSchema) + .passthrough(); const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict(); + const legacyAgentToAgentCreateAgentArgsSchema = z.object(legacyAgentToAgentInputSchema).strict(); const canonicalTopLevelCreateAgentArgsSchema = z.object(canonicalTopLevelInputSchema).strict(); - const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema).strict(); + const legacyTopLevelCreateAgentArgsSchema = z + .object(legacyTopLevelCreateAgentInputSchema) + .strict(); const commonSendAgentPromptInputSchema = { agentId: z.string(), prompt: z.string(), @@ -980,8 +1141,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ), }; type AgentToAgentCreateAgentArgs = z.infer<typeof agentToAgentCreateAgentArgsSchema>; + type LegacyAgentToAgentCreateAgentArgs = z.infer<typeof legacyAgentToAgentCreateAgentArgsSchema>; type TopLevelCreateAgentArgs = z.infer<typeof canonicalTopLevelCreateAgentArgsSchema>; - type TopLevelCreateAgentToolArgs = z.infer<typeof topLevelCreateAgentArgsSchema>; + type LegacyTopLevelCreateAgentArgs = z.infer<typeof legacyTopLevelCreateAgentArgsSchema>; if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) { registerTool( @@ -1035,12 +1197,201 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }); } + registerTool( + "create_workspace", + { + title: "Create workspace", + description: + "Create a workspace using an existing local checkout or a new Paseo-managed worktree.", + inputSchema: { + isolation: z.enum(["local", "worktree"]), + path: z + .string() + .optional() + .describe("Local directory or source checkout. Defaults to your current workspace."), + projectId: z.string().optional().describe("Existing project id to own the workspace."), + title: z.string().trim().min(1).optional(), + mode: z + .enum(["branch-off", "checkout-branch", "checkout-pr"]) + .optional() + .describe("Worktree creation mode. Defaults to branch-off."), + worktreeSlug: z.string().trim().min(1).optional(), + branchName: z + .string() + .trim() + .min(1) + .optional() + .describe("New branch name for branch-off mode."), + baseBranch: z.string().trim().min(1).optional().describe("Base ref for branch-off mode."), + branch: z + .string() + .trim() + .min(1) + .optional() + .describe("Existing branch for checkout-branch mode."), + prNumber: z + .number() + .int() + .positive() + .optional() + .describe("Pull request or change request number for checkout-pr mode."), + forge: z + .string() + .trim() + .min(1) + .optional() + .describe("Forge for checkout-pr mode. Defaults to the source checkout."), + }, + outputSchema: WorkspaceAutomationSummarySchema.shape, + }, + async ({ + isolation, + path, + projectId, + title, + mode, + worktreeSlug, + branchName, + baseBranch, + branch, + prNumber, + forge, + }) => { + let workspace: PersistedWorkspaceRecord; + if (isolation === "local") { + const cwd = resolveScopedCwd(path, { required: true }); + assertOptionsAbsent( + [ + ["mode", mode], + ["worktreeSlug", worktreeSlug], + ["branchName", branchName], + ["baseBranch", baseBranch], + ["branch", branch], + ["prNumber", prNumber], + ["forge", forge], + ], + "Worktree options require isolation worktree", + ); + if (!options.createDirectoryWorkspace) { + throw new Error("Workspace provisioning is not configured"); + } + workspace = await options.createDirectoryWorkspace(cwd, title, projectId); + } else { + let cwd = + path !== undefined || !projectId ? resolveScopedCwd(path, { required: true }) : null; + if (!cwd) { + if (!options.projectRegistry) { + throw new Error("Project registry is not configured"); + } + cwd = await resolveWorktreeSourceCwd({ projectId }, options.projectRegistry); + } + const worktreeTarget = resolveWorkspaceWorktreeTarget({ + mode, + worktreeSlug, + branchName, + baseBranch, + branch, + prNumber, + forge, + }); + const result = await createPaseoWorktreeCommand( + { + paseoHome: options.paseoHome, + worktreesRoot: options.worktreesRoot, + createPaseoWorktreeWorkflow: options.createPaseoWorktree, + }, + { + cwd, + ...(projectId ? { projectId } : {}), + ...(worktreeSlug ? { worktreeSlug } : {}), + ...worktreeTarget, + ...(title ? { title } : {}), + }, + ); + if (!result.ok) { + throw result.cause; + } + workspace = result.createdWorktree.workspace; + } + + return { + content: [], + structuredContent: ensureValidJson(toWorkspaceAutomationSummary(workspace)), + }; + }, + ); + + registerTool( + "list_workspaces", + { + title: "List workspaces", + description: "List active workspaces.", + inputSchema: {}, + outputSchema: { workspaces: z.array(WorkspaceAutomationSummarySchema) }, + }, + async () => { + if (!options.workspaceRegistry) { + throw new Error("Workspace registry is not configured"); + } + const workspaces = (await options.workspaceRegistry.list()) + .filter((workspace) => !workspace.archivedAt) + .map(toWorkspaceAutomationSummary); + return { + content: [], + structuredContent: ensureValidJson({ workspaces }), + }; + }, + ); + + registerTool( + "archive_workspace", + { + title: "Archive workspace", + description: "Archive a workspace and everything it owns.", + inputSchema: { workspaceId: z.string().min(1) }, + outputSchema: { + workspaceId: z.string(), + archivedAgentIds: z.array(z.string()), + removedDirectory: z.boolean(), + }, + }, + async ({ workspaceId }) => { + if (!options.listActiveWorkspaces) { + throw new Error("Active workspace lister is required to archive workspaces"); + } + const workspace = await requireActiveWorkspaceForArchive( + { listActiveWorkspaces: options.listActiveWorkspaces }, + workspaceId, + ); + const result = await archiveByScope( + archiveWorktreeDependencies(options, { + agentManager, + agentStorage, + terminalManager: terminalManager ?? null, + logger: childLogger, + }), + { + requestId: "mcp:archive_workspace", + scope: { kind: "workspace", workspaceId: workspace.workspaceId }, + }, + ); + return { + content: [], + structuredContent: ensureValidJson({ + workspaceId, + archivedAgentIds: result.archivedAgentIds, + removedDirectory: result.removedDirectory, + }), + }; + }, + ); + registerTool( "create_agent", { title: "Create agent", description: - "Create an agent. Requires relationship, workspace, provider/model (for example codex/gpt-5.4), and an initial prompt. Do not guess; call list_providers and list_models first if uncertain.", + "Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. Do not guess; call list_providers and list_models first if uncertain.", inputSchema: createAgentInputSchema, outputSchema: { agentId: z.string(), @@ -1060,15 +1411,12 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase const { parsedArgs, worktree } = resolvedArgs; let requestedBackground: boolean; let notifyOnFinish: boolean; - let detached: boolean; if (resolvedArgs.kind === "agent-scoped") { requestedBackground = true; notifyOnFinish = parsedArgs.notifyOnFinish; - detached = resolvedArgs.relationship.kind === "detached"; } else { requestedBackground = resolvedArgs.parsedArgs.background; notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false; - detached = resolvedArgs.parsedArgs.relationship.kind === "detached"; } const { snapshot, @@ -1101,7 +1449,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase mode: parsedArgs.settings?.modeId, background: requestedBackground, notifyOnFinish, - detached, + detached: resolvedArgs.detached, callerAgentId, callerContext, worktree, @@ -1167,15 +1515,16 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase type ResolvedCreateAgentToolArgs = | { kind: "agent-scoped"; - parsedArgs: AgentToAgentCreateAgentArgs; - relationship: AgentToAgentCreateAgentArgs["relationship"]; + parsedArgs: AgentToAgentCreateAgentArgs | LegacyAgentToAgentCreateAgentArgs; + detached: boolean; cwd: string | undefined; workspaceId: string | undefined; worktree: CreateAgentFromMcpInput["worktree"]; } | { kind: "top-level"; - parsedArgs: TopLevelCreateAgentArgs; + parsedArgs: TopLevelCreateAgentArgs | LegacyTopLevelCreateAgentArgs; + detached: boolean; cwd: string | undefined; workspaceId: string | undefined; worktree: CreateAgentFromMcpInput["worktree"]; @@ -1183,38 +1532,125 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase async function resolveCreateAgentToolArgs(args: unknown): Promise<ResolvedCreateAgentToolArgs> { if (callerAgentId) { + if (hasLegacyCreateAgentPlacement(args)) { + // COMPAT(nestedCreateAgentPlacement): accept the old relationship/workspace shape without + // advertising it to models. Added in v0.2.0; remove after 2027-01-17. + const parsed = legacyAgentToAgentCreateAgentArgsSchema.parse(args); + const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace, { + prompt: parsed.initialPrompt, + }); + return { + kind: "agent-scoped", + parsedArgs: parsed, + detached: parsed.relationship.kind === "detached", + cwd, + workspaceId, + worktree, + }; + } const parsed = agentToAgentCreateAgentArgsSchema.parse(args); - const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace, { + const { cwd, workspaceId } = await resolveCanonicalCreateAgentWorkspace(parsed.workspaceId, { prompt: parsed.initialPrompt, }); return { kind: "agent-scoped", parsedArgs: parsed, - relationship: parsed.relationship, + detached: false, + cwd, + workspaceId, + worktree: undefined, + }; + } + if (hasLegacyCreateAgentPlacement(args)) { + // COMPAT(nestedCreateAgentPlacement): see the agent-scoped branch above. + const parsedArgs = normalizeTopLevelCreateAgentArgs( + legacyTopLevelCreateAgentArgsSchema.parse(args), + ); + if (parsedArgs.relationship?.kind === "subagent") { + throw new Error("relationship subagent requires an agent-scoped tool session"); + } + if (!parsedArgs.workspace) { + throw new Error("Legacy create_agent placement could not be resolved"); + } + const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace( + parsedArgs.workspace, + { prompt: parsedArgs.initialPrompt }, + ); + return { + kind: "top-level", + parsedArgs, + detached: true, cwd, workspaceId, worktree, }; } - const parsedArgs = normalizeTopLevelCreateAgentArgs(topLevelCreateAgentArgsSchema.parse(args)); - if (parsedArgs.relationship.kind === "subagent") { - throw new Error("relationship subagent requires an agent-scoped tool session"); - } - const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsedArgs.workspace, { - prompt: parsedArgs.initialPrompt, - }); + const parsedArgs = canonicalTopLevelCreateAgentArgsSchema.parse(args); + const { cwd, workspaceId } = await resolveCanonicalCreateAgentWorkspace( + parsedArgs.workspaceId, + { prompt: parsedArgs.initialPrompt }, + ); return { kind: "top-level", parsedArgs, + detached: false, cwd, workspaceId, - worktree, + worktree: undefined, }; } + function hasLegacyCreateAgentPlacement(args: unknown): boolean { + if (!args || typeof args !== "object") { + return false; + } + const input = args as Record<string, unknown>; + return [ + "relationship", + "workspace", + "cwd", + "worktreeName", + "branchName", + "baseBranch", + "refName", + "githubPrNumber", + ].some((key) => input[key] !== undefined); + } + + async function resolveCanonicalCreateAgentWorkspace( + workspaceId?: string, + firstAgentContext?: FirstAgentContext, + ): Promise<{ + cwd: string | undefined; + workspaceId: string; + }> { + if (workspaceId) { + const resolved = await resolveCreateAgentWorkspace( + { kind: "existing", workspaceId }, + undefined, + ); + return { cwd: resolved.cwd, workspaceId }; + } + if (!callerAgentId) { + if (!options.ensureWorkspaceForCreate) { + throw new Error("Workspace creation is not configured"); + } + const cwd = process.cwd(); + return { + cwd, + workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext), + }; + } + const caller = resolveCallerAgent(); + if (!caller?.workspaceId) { + throw new Error(`Caller agent ${callerAgentId} has no current workspace`); + } + return { cwd: undefined, workspaceId: caller.workspaceId }; + } + function normalizeTopLevelCreateAgentArgs( - args: TopLevelCreateAgentToolArgs, - ): TopLevelCreateAgentArgs { + args: LegacyTopLevelCreateAgentArgs, + ): LegacyTopLevelCreateAgentArgs { const { cwd, mode, @@ -1235,7 +1671,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; if (canonicalCandidate.relationship && canonicalCandidate.workspace) { - return canonicalTopLevelCreateAgentArgsSchema.parse({ + return legacyTopLevelCreateAgentArgsSchema.parse({ ...canonicalCandidate, ...(Object.keys(settings).length > 0 ? { settings } : {}), }); @@ -1273,7 +1709,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, }; - return canonicalTopLevelCreateAgentArgsSchema.parse({ + return legacyTopLevelCreateAgentArgsSchema.parse({ ...canonicalCandidate, relationship: { kind: "detached" }, workspace, @@ -1315,7 +1751,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } async function resolveCreateAgentWorkspace( - workspace: AgentToAgentCreateAgentArgs["workspace"] | TopLevelCreateAgentArgs["workspace"], + workspace: + | LegacyAgentToAgentCreateAgentArgs["workspace"] + | NonNullable<LegacyTopLevelCreateAgentArgs["workspace"]>, firstAgentContext: FirstAgentContext | undefined, ): Promise<{ cwd: string | undefined; @@ -1995,16 +2433,17 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase .optional() .describe("IANA time zone for the cron cadence. For example: America/New_York."), name: z.string().optional(), - provider: AgentProviderEnum.optional().describe( - "Provider, or provider/model (for example: codex or codex/gpt-5.4).", + provider: (callerAgentId ? AgentProviderEnum.optional() : AgentProviderEnum).describe( + "Provider, or provider/model (for example: codex or codex/gpt-5.4). Defaults to the caller's provider in an agent-scoped session.", ), cwd: z.string().optional(), + isolation: z.enum(["local", "worktree"]).optional(), maxRuns: z.number().int().positive().optional(), expiresIn: z.string().optional(), }, outputSchema: ScheduleSummarySchema.shape, }, - async ({ prompt, cron, timezone, name, provider, cwd, maxRuns, expiresIn }) => { + async ({ prompt, cron, timezone, name, provider, cwd, isolation, maxRuns, expiresIn }) => { if (!scheduleService) { throw new Error("Schedule service is not configured"); } @@ -2016,7 +2455,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase cron, ...(timezone !== undefined ? { timezone } : {}), }), - target: resolveNewAgentScheduleTarget({ provider, cwd }), + target: resolveNewAgentScheduleTarget({ provider, cwd, isolation }), ...(name?.trim() ? { name: name.trim() } : {}), ...(maxRuns === undefined ? {} : { maxRuns }), ...(expiresAt === undefined ? {} : { expiresAt }), @@ -2078,6 +2517,27 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, ); + registerTool( + "delete_heartbeat", + { + title: "Delete heartbeat", + description: "Delete one of your heartbeats.", + inputSchema: { id: z.string().min(1) }, + outputSchema: { success: z.boolean() }, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + await requireCallerHeartbeat(id); + await scheduleService.delete(id); + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + registerTool( "list_schedules", { @@ -2093,9 +2553,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase throw new Error("Schedule service is not configured"); } - const schedules = (await scheduleService.list()).map((schedule) => - toScheduleSummary(schedule), - ); + const schedules = (await scheduleService.list()) + .filter((schedule) => schedule.target.type === "new-agent") + .map((schedule) => toScheduleSummary(schedule)); return { content: [], structuredContent: ensureValidJson({ schedules }), @@ -2118,7 +2578,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase throw new Error("Schedule service is not configured"); } - const schedule = await scheduleService.inspect(id); + const schedule = await requireScheduleTarget(id, "new-agent"); return { content: [], structuredContent: ensureValidJson(schedule), @@ -2143,6 +2603,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase throw new Error("Schedule service is not configured"); } + await requireScheduleTarget(id, "new-agent"); await scheduleService.pause(id); return { content: [], @@ -2168,6 +2629,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase throw new Error("Schedule service is not configured"); } + await requireScheduleTarget(id, "new-agent"); await scheduleService.resume(id); return { content: [], @@ -2193,6 +2655,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase throw new Error("Schedule service is not configured"); } + await requireScheduleTarget(id, "new-agent"); await scheduleService.delete(id); return { content: [], @@ -2207,54 +2670,55 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase title: "Update schedule", description: "Update an existing schedule. Only provided fields are changed; omitted fields remain unchanged.", - inputSchema: { - id: z.string(), - every: z.string().optional().describe("New interval duration string (e.g. 5m, 1h)."), - cron: z.string().optional().describe("New cron expression."), - timezone: z - .string() - .trim() - .min(1) - .optional() - .describe( - "IANA time zone for cron cadence; requires cron. For example: America/New_York.", - ), - name: z.string().nullable().optional().describe("New name (null to clear)."), - prompt: z.string().trim().min(1).optional().describe("New prompt text."), - maxRuns: z - .number() - .int() - .positive() - .nullable() - .optional() - .describe("New max runs limit (null to clear)."), - provider: z - .string() - .trim() - .min(1) - .optional() - .describe("New provider for new-agent target."), - model: z - .string() - .trim() - .min(1) - .nullable() - .optional() - .describe("New model for new-agent target (null to clear)."), - mode: z - .string() - .trim() - .min(1) - .nullable() - .optional() - .describe("New mode for new-agent target (null to clear)."), - cwd: z.string().trim().min(1).optional().describe("New cwd for new-agent target."), - expiresIn: z - .string() - .optional() - .describe("New relative expiry duration (for example: 1h, 2d)."), - clearExpires: z.boolean().optional().describe("Clear any schedule expiry."), - }, + inputSchema: z + .object({ + id: z.string(), + cron: z.string().optional().describe("New cron expression."), + timezone: z + .string() + .trim() + .min(1) + .optional() + .describe( + "IANA time zone for cron cadence; requires cron. For example: America/New_York.", + ), + name: z.string().nullable().optional().describe("New name (null to clear)."), + prompt: z.string().trim().min(1).optional().describe("New prompt text."), + maxRuns: z + .number() + .int() + .positive() + .nullable() + .optional() + .describe("New max runs limit (null to clear)."), + provider: z + .string() + .trim() + .min(1) + .optional() + .describe("New provider for new-agent target."), + model: z + .string() + .trim() + .min(1) + .nullable() + .optional() + .describe("New model for new-agent target (null to clear)."), + mode: z + .string() + .trim() + .min(1) + .nullable() + .optional() + .describe("New mode for new-agent target (null to clear)."), + cwd: z.string().trim().min(1).optional().describe("New cwd for new-agent target."), + expiresIn: z + .string() + .optional() + .describe("New relative expiry duration (for example: 1h, 2d)."), + clearExpires: z.boolean().optional().describe("Clear any schedule expiry."), + }) + .passthrough(), outputSchema: StoredScheduleSchema.shape, }, async (input) => { @@ -2262,6 +2726,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase throw new Error("Schedule service is not configured"); } + await requireScheduleTarget(input.id, "new-agent"); const schedule = await scheduleService.update(buildScheduleUpdateInput(input)); return { @@ -2288,6 +2753,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase throw new Error("Schedule service is not configured"); } + await requireScheduleTarget(id, "new-agent"); const runs = await scheduleService.logs(id); return { content: [], @@ -2296,6 +2762,27 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, ); + registerTool( + "run_schedule_once", + { + title: "Run schedule once", + description: "Run a schedule immediately without changing its cron cadence.", + inputSchema: { id: z.string().min(1) }, + outputSchema: StoredScheduleSchema.shape, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + await requireScheduleTarget(id, "new-agent"); + const schedule = await scheduleService.runOnce(id); + return { + content: [], + structuredContent: ensureValidJson(schedule), + }; + }, + ); + registerTool( "list_providers", { @@ -2407,146 +2894,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, ); - registerTool( - "list_worktrees", - { - title: "List worktrees", - description: "List Paseo-managed git worktrees for a repository.", - inputSchema: { - cwd: z - .string() - .optional() - .describe("Optional repository cwd. Defaults to your current working directory."), - }, - outputSchema: { - worktrees: z.array(WorktreeSummarySchema), - }, - }, - async ({ cwd }) => { - const resolvedCwd = resolveScopedCwd(cwd, { required: true }); - if (!options.workspaceGitService) { - throw new Error("WorkspaceGitService is required to list worktrees"); - } - const worktrees = await listPaseoWorktreesCommand( - { workspaceGitService: options.workspaceGitService }, - { - cwd: resolvedCwd, - reason: "mcp:list-worktrees", - }, - ); - - return { - content: [], - structuredContent: ensureValidJson({ worktrees }), - }; - }, - ); - - registerTool( - "create_worktree", - { - title: "Create worktree", - description: - "Create a Paseo-managed git worktree. Branch off a new branch, check out an existing branch, or check out a GitHub PR.", - inputSchema: { - cwd: z.string().optional().describe("Repository directory. Defaults to the agent's cwd."), - target: AgentCreateWorktreeTargetInputSchema.describe("What the worktree should contain."), - }, - outputSchema: { - branchName: z.string(), - worktreePath: z.string(), - workspaceId: z.string(), - }, - }, - async ({ cwd, target }) => { - const repoRoot = resolveScopedCwd(cwd, { required: true }); - const commandResult = await createPaseoWorktreeCommand( - { - paseoHome: options.paseoHome, - worktreesRoot: options.worktreesRoot, - createPaseoWorktreeWorkflow: options.createPaseoWorktree, - }, - createMcpWorktreeCommandInput(repoRoot, target), - ); - if (!commandResult.ok) { - throw new WorktreeRequestError(commandResult.error); - } - const { worktree, workspace } = commandResult.createdWorktree; - await options.workspaceGitService?.listWorktrees?.(repoRoot, { - force: true, - reason: "mcp:create-worktree", - }); - - return { - content: [], - structuredContent: ensureValidJson({ - branchName: worktree.branchName, - worktreePath: worktree.worktreePath, - workspaceId: workspace.workspaceId, - }), - }; - }, - ); - - registerTool( - "archive_worktree", - { - title: "Archive worktree", - description: "Delete a Paseo-managed git worktree.", - inputSchema: { - cwd: z - .string() - .optional() - .describe("Optional repository cwd. Defaults to your current working directory."), - worktreePath: z.string().optional(), - worktreeSlug: z.string().optional(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ cwd, worktreePath, worktreeSlug }) => { - const resolvedCwd = resolveScopedCwd(cwd, { required: true }); - if (!worktreePath && !worktreeSlug) { - throw new Error("worktreePath or worktreeSlug is required"); - } - if (!options.workspaceGitService) { - throw new Error("WorkspaceGitService is required to archive worktrees"); - } - const repoRoot = await options.workspaceGitService.resolveRepoRoot(resolvedCwd); - - const result = await archiveCommand( - archiveWorktreeDependencies(options, { - agentManager, - agentStorage, - terminalManager: terminalManager ?? null, - logger: childLogger, - }), - { - requestId: "mcp:archive_worktree", - repoRoot, - worktreePath, - worktreeSlug, - // This tool archives every workspace on the directory, then removes the - // directory. Disk removal is derived from scope + last-reference. - scope: "worktree", - }, - ); - if (!result.ok) { - throw new Error(result.message); - } - await options.workspaceGitService.listWorktrees(repoRoot, { - force: true, - reason: "mcp:archive-worktree", - }); - - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - registerTool( "get_agent_activity", { @@ -2694,11 +3041,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase return toCatalog(); } -type McpCreateWorktreeTarget = - | { kind: "branch-off"; worktreeSlug?: string; branchName?: string; baseBranch?: string } - | { kind: "checkout-branch"; branch: string } - | { kind: "checkout-pr"; githubPrNumber: number }; - interface ArchiveWorktreeCommandContext { agentManager: AgentManager; agentStorage: AgentStorage; @@ -2758,26 +3100,3 @@ function archiveWorktreeDependencies( sessionLogger: context.logger, }; } - -function createMcpWorktreeCommandInput( - repoRoot: string, - target: McpCreateWorktreeTarget, -): CreatePaseoWorktreeCommandInput { - const base = { cwd: repoRoot } as const; - switch (target.kind) { - case "branch-off": - return { - ...base, - worktreeSlug: target.worktreeSlug, - branchName: target.branchName, - action: "branch-off", - ...(target.baseBranch ? { refName: target.baseBranch } : {}), - }; - case "checkout-branch": - return { ...base, action: "checkout", refName: target.branch }; - case "checkout-pr": - return { ...base, action: "checkout", githubPrNumber: target.githubPrNumber }; - default: - throw new Error("unreachable"); - } -} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 0157f81e2..c9d27c881 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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, diff --git a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts index b99295e46..e0524c894 100644 --- a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts +++ b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts @@ -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); diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index bd30a28a1..13bb5ec79 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -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}`]); diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 43a8570d9..818d98753 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -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 }); diff --git a/packages/server/src/server/schedule/cron.ts b/packages/server/src/server/schedule/cron.ts index ab24746a5..2ac2e7897 100644 --- a/packages/server/src/server/schedule/cron.ts +++ b/packages/server/src/server/schedule/cron.ts @@ -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); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8476973c4..7d7ad185a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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( { diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 2d9a33fe2..8b6045a44 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -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; diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index f2b2b299e..2fbdc930c 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -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(); diff --git a/packages/server/src/server/workspace-source.ts b/packages/server/src/server/workspace-source.ts new file mode 100644 index 000000000..21a53e627 --- /dev/null +++ b/packages/server/src/server/workspace-source.ts @@ -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; +} diff --git a/skills/paseo-handoff/SKILL.md b/skills/paseo-handoff/SKILL.md index 45f2ea4ae..102297544 100644 --- a/skills/paseo-handoff/SKILL.md +++ b/skills/paseo-handoff/SKILL.md @@ -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). diff --git a/skills/paseo-loop/SKILL.md b/skills/paseo-loop/SKILL.md index 966983e8d..2ee7be549 100644 --- a/skills/paseo-loop/SKILL.md +++ b/skills/paseo-loop/SKILL.md @@ -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 diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 198b0a0fb..c94595b93 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -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`.