mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Make workspace, agent, and schedule automation consistent (#2186)
* feat(workspaces): align agent and schedule automation Make workspace identity the shared placement boundary for MCP and CLI, while caller identity determines parentage. Keep heartbeats minimal and cron-based without changing legacy rolling interval semantics. * test(cli): expect canonical schedule cadence * fix(automation): preserve workspace and schedule compatibility * fix(automation): preserve compatibility edges * fix(workspaces): align MCP lifecycle resolution * fix(workspaces): preserve creation intent * fix(workspaces): preserve branch and schedule identity
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -82,7 +82,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for `create_agent` subagent relationships — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
@@ -289,8 +289,8 @@ One file per schedule. ID is 8 hex characters.
|
||||
|
||||
### Nested: ScheduleCadence (discriminated union on `type`)
|
||||
|
||||
- `{ type: "every", everyMs: number }` — interval in milliseconds
|
||||
- `{ type: "cron", expression: string, timezone?: string }` — cron expression; absent `timezone` means UTC, present `timezone` is an IANA time zone used for local wall-clock recurrence
|
||||
- `{ type: "cron", expression: string, timezone?: string }` — canonical cadence for new writes; absent `timezone` means UTC
|
||||
- `{ type: "every", everyMs: number }` — legacy rolling interval, still readable and executable during the compatibility window
|
||||
|
||||
### Nested: ScheduleTarget (discriminated union on `type`)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Text, View } from "react-native";
|
||||
import { Brain, Folder, GitBranch } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { ComboboxItem } from "@/components/ui/combobox";
|
||||
@@ -71,6 +71,15 @@ function parseMaxRuns(raw: string): number | null {
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function requireCronCadence(
|
||||
cadence: Extract<ScheduleCadence, { type: "cron" }> | undefined,
|
||||
): Extract<ScheduleCadence, { type: "cron" }> {
|
||||
if (!cadence) {
|
||||
throw new Error("Choose a cron cadence before creating this schedule");
|
||||
}
|
||||
return cadence;
|
||||
}
|
||||
|
||||
function resolveCreateServerId(input: {
|
||||
mode: "create" | "edit";
|
||||
serverId: string | null | undefined;
|
||||
@@ -306,18 +315,15 @@ function OpenScheduleFormSheet({
|
||||
]);
|
||||
|
||||
const submitAgentTarget = useCallback(async (): Promise<boolean> => {
|
||||
if (!schedule) {
|
||||
if (!schedule || !state.submitCadence) {
|
||||
return false;
|
||||
}
|
||||
await updateSchedule({
|
||||
id: schedule.id,
|
||||
name: state.name.trim() || null,
|
||||
prompt: state.prompt.trim(),
|
||||
cadence: state.submitCadence,
|
||||
maxRuns: parseMaxRuns(state.maxRuns),
|
||||
});
|
||||
return true;
|
||||
}, [schedule, state.maxRuns, state.name, state.prompt, state.submitCadence, updateSchedule]);
|
||||
}, [schedule, state.submitCadence, updateSchedule]);
|
||||
|
||||
const submitNewAgent = useCallback(async (): Promise<boolean> => {
|
||||
const provider = state.selectedProvider;
|
||||
@@ -333,7 +339,7 @@ function OpenScheduleFormSheet({
|
||||
id: schedule.id,
|
||||
name: state.name.trim() || null,
|
||||
prompt: state.prompt.trim(),
|
||||
cadence: state.submitCadence,
|
||||
...(state.submitCadence ? { cadence: state.submitCadence } : {}),
|
||||
newAgentConfig: {
|
||||
provider,
|
||||
model: state.selectedModel || null,
|
||||
@@ -353,7 +359,7 @@ function OpenScheduleFormSheet({
|
||||
await createSchedule({
|
||||
prompt: state.prompt.trim(),
|
||||
name: state.name.trim() || undefined,
|
||||
cadence: state.submitCadence,
|
||||
cadence: requireCronCadence(state.submitCadence),
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
@@ -394,10 +400,12 @@ function OpenScheduleFormSheet({
|
||||
void handleSubmit();
|
||||
}, [handleSubmit]);
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: mode === "edit" ? "Edit schedule" : "New schedule" }),
|
||||
[mode],
|
||||
);
|
||||
const header = useMemo<SheetHeader>(() => {
|
||||
if (mode !== "edit") {
|
||||
return { title: "New schedule" };
|
||||
}
|
||||
return { title: schedule?.target.type === "agent" ? "Edit heartbeat" : "Edit schedule" };
|
||||
}, [mode, schedule?.target.type]);
|
||||
|
||||
const footer = useMemo(
|
||||
() => (
|
||||
@@ -466,6 +474,21 @@ function ScheduleFormFields({
|
||||
cadenceError,
|
||||
mutationServerId,
|
||||
}: ScheduleFormFieldsProps): ReactElement {
|
||||
if (state.targetKind === "agent") {
|
||||
return (
|
||||
<>
|
||||
<ScheduleAgentTargetField label={agentTargetLabel} size={controlSize} />
|
||||
<CadenceEditor
|
||||
value={state.cadence}
|
||||
onChange={model.setCadence}
|
||||
error={cadenceError ?? undefined}
|
||||
size={controlSize}
|
||||
/>
|
||||
{state.submitError ? <Text style={styles.submitError}>{state.submitError}</Text> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field label="Name">
|
||||
@@ -502,7 +525,7 @@ function ScheduleFormFields({
|
||||
model={model}
|
||||
state={state}
|
||||
providerSnapshot={providerSnapshot}
|
||||
agentTargetLabel={agentTargetLabel}
|
||||
agentTargetLabel={null}
|
||||
controlSize={controlSize}
|
||||
mutationServerId={mutationServerId}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,12 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
|
||||
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
|
||||
import {
|
||||
formatCadence,
|
||||
formatNextRun,
|
||||
resolveScheduleTitle,
|
||||
scheduleProductName,
|
||||
} from "@/utils/schedule-format";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
@@ -153,9 +158,10 @@ export function ScheduleRow({
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
|
||||
const title = resolveScheduleTitle(schedule);
|
||||
const productName = scheduleProductName(schedule);
|
||||
const badge = stateBadge(state);
|
||||
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
|
||||
const canRun = state === "active" || state === "paused";
|
||||
const canRun = schedule.target.type === "new-agent" && (state === "active" || state === "paused");
|
||||
|
||||
const rowStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
@@ -178,7 +184,7 @@ export function ScheduleRow({
|
||||
style={rowStyle}
|
||||
onPress={onEdit}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Edit schedule ${title}`}
|
||||
accessibilityLabel={`Edit ${productName.toLowerCase()} ${title}`}
|
||||
testID={`schedule-row-${schedule.id}`}
|
||||
>
|
||||
<View style={styles.main}>
|
||||
@@ -222,6 +228,66 @@ const resumeLeading = <ThemedPlay size={MENU_ICON_SIZE} uniProps={mutedColorMapp
|
||||
const runLeading = <ThemedRotateCw size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||
const deleteLeading = <ThemedTrash2 size={MENU_ICON_SIZE} uniProps={destructiveColorMapping} />;
|
||||
|
||||
function ScheduleExecutionMenuItems({
|
||||
schedule,
|
||||
canRun,
|
||||
pending,
|
||||
onPause,
|
||||
onResume,
|
||||
onRunNow,
|
||||
}: Pick<ScheduleRowProps, "schedule" | "pending" | "onPause" | "onResume" | "onRunNow"> & {
|
||||
canRun: boolean;
|
||||
}): ReactElement | null {
|
||||
if (schedule.target.type === "agent") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cadenceAction: ReactElement;
|
||||
if (schedule.status === "paused") {
|
||||
cadenceAction = (
|
||||
<DropdownMenuItem
|
||||
leading={resumeLeading}
|
||||
disabled={!canRun}
|
||||
status={pending?.resume ? "pending" : "idle"}
|
||||
pendingLabel="Resuming..."
|
||||
onSelect={onResume}
|
||||
testID={`schedule-menu-resume-${schedule.id}`}
|
||||
>
|
||||
Resume schedule
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
} else {
|
||||
cadenceAction = (
|
||||
<DropdownMenuItem
|
||||
leading={pauseLeading}
|
||||
disabled={schedule.status === "completed" || !canRun}
|
||||
status={pending?.pause ? "pending" : "idle"}
|
||||
pendingLabel="Pausing..."
|
||||
onSelect={onPause}
|
||||
testID={`schedule-menu-pause-${schedule.id}`}
|
||||
>
|
||||
Pause schedule
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{cadenceAction}
|
||||
<DropdownMenuItem
|
||||
leading={runLeading}
|
||||
disabled={!canRun}
|
||||
status={pending?.runNow ? "pending" : "idle"}
|
||||
pendingLabel="Starting..."
|
||||
onSelect={onRunNow}
|
||||
testID={`schedule-menu-run-${schedule.id}`}
|
||||
>
|
||||
Run now
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElement {
|
||||
return (
|
||||
<ThemedKebab
|
||||
@@ -246,13 +312,15 @@ function ScheduleKebabMenu({
|
||||
> & {
|
||||
canRun: boolean;
|
||||
}): ReactElement {
|
||||
const productName = scheduleProductName(schedule);
|
||||
const productNameLower = productName.toLowerCase();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={kebabTriggerStyle}
|
||||
accessibilityRole={isNative ? "button" : undefined}
|
||||
accessibilityLabel="Schedule actions"
|
||||
accessibilityLabel={`${productName} actions`}
|
||||
testID={`schedule-kebab-${schedule.id}`}
|
||||
>
|
||||
{renderKebabTriggerIcon}
|
||||
@@ -263,41 +331,16 @@ function ScheduleKebabMenu({
|
||||
onSelect={onEdit}
|
||||
testID={`schedule-menu-edit-${schedule.id}`}
|
||||
>
|
||||
Edit schedule
|
||||
</DropdownMenuItem>
|
||||
{schedule.status === "paused" ? (
|
||||
<DropdownMenuItem
|
||||
leading={resumeLeading}
|
||||
disabled={!canRun}
|
||||
status={pending?.resume ? "pending" : "idle"}
|
||||
pendingLabel="Resuming..."
|
||||
onSelect={onResume}
|
||||
testID={`schedule-menu-resume-${schedule.id}`}
|
||||
>
|
||||
Resume schedule
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
leading={pauseLeading}
|
||||
disabled={schedule.status === "completed" || !canRun}
|
||||
status={pending?.pause ? "pending" : "idle"}
|
||||
pendingLabel="Pausing..."
|
||||
onSelect={onPause}
|
||||
testID={`schedule-menu-pause-${schedule.id}`}
|
||||
>
|
||||
Pause schedule
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
leading={runLeading}
|
||||
disabled={!canRun}
|
||||
status={pending?.runNow ? "pending" : "idle"}
|
||||
pendingLabel="Starting..."
|
||||
onSelect={onRunNow}
|
||||
testID={`schedule-menu-run-${schedule.id}`}
|
||||
>
|
||||
Run now
|
||||
Edit {productNameLower}
|
||||
</DropdownMenuItem>
|
||||
<ScheduleExecutionMenuItems
|
||||
schedule={schedule}
|
||||
canRun={canRun}
|
||||
pending={pending}
|
||||
onPause={onPause}
|
||||
onResume={onResume}
|
||||
onRunNow={onRunNow}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
leading={deleteLeading}
|
||||
@@ -307,7 +350,7 @@ function ScheduleKebabMenu({
|
||||
onSelect={onDelete}
|
||||
testID={`schedule-menu-delete-${schedule.id}`}
|
||||
>
|
||||
Delete schedule
|
||||
Delete {productNameLower}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface ScheduleFormProjectOption {
|
||||
}
|
||||
|
||||
export type ScheduleFormTargetKind = "agent" | "new-agent";
|
||||
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
|
||||
type ProviderResolutionStatus = "idle" | "pending" | "complete";
|
||||
|
||||
export interface ScheduleFormState {
|
||||
@@ -86,7 +87,7 @@ export interface ScheduleFormState {
|
||||
prompt: string;
|
||||
maxRuns: string;
|
||||
cadence: ScheduleCadence;
|
||||
submitCadence: ScheduleCadence;
|
||||
submitCadence: CronCadence | undefined;
|
||||
hosts: ScheduleFormHost[];
|
||||
projectOptions: ScheduleFormProjectOption[];
|
||||
selectedServerId: string | null;
|
||||
@@ -436,13 +437,10 @@ function formatInitialMaxRuns(schedule: ScheduleFormSnapshot["schedule"]): strin
|
||||
}
|
||||
|
||||
function resolveInitialSubmitCadence(
|
||||
snapshot: ScheduleFormSnapshot,
|
||||
initialCadence: ScheduleCadence,
|
||||
): ScheduleCadence {
|
||||
if (snapshot.mode === "edit" && snapshot.schedule) {
|
||||
return snapshot.schedule.cadence;
|
||||
}
|
||||
return initialCadence;
|
||||
schedule: ScheduleFormSnapshot["schedule"],
|
||||
initialCadence: CronCadence,
|
||||
): CronCadence | undefined {
|
||||
return schedule ? undefined : initialCadence;
|
||||
}
|
||||
|
||||
function resolveInitialIsolation(input: {
|
||||
@@ -547,12 +545,12 @@ function resolveDisclosure(state: ScheduleFormState): ScheduleDisclosureState {
|
||||
}
|
||||
|
||||
function resolveCanSubmit(state: ScheduleFormState): boolean {
|
||||
if (state.targetKind === "agent") {
|
||||
return state.submitCadence !== undefined;
|
||||
}
|
||||
if (state.prompt.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (state.targetKind === "agent") {
|
||||
return true;
|
||||
}
|
||||
const hasWorkingDir = state.workingDir.trim().length > 0;
|
||||
const hasMatchedProject = state.selectedProjectOptionId.trim().length > 0;
|
||||
if (state.mode === "create" && !hasMatchedProject) {
|
||||
@@ -657,7 +655,7 @@ function buildInitialState(snapshot: ScheduleFormSnapshot): ScheduleFormState {
|
||||
prompt: snapshot.schedule?.prompt ?? "",
|
||||
maxRuns: formatInitialMaxRuns(snapshot.schedule),
|
||||
cadence: initialCadence,
|
||||
submitCadence: resolveInitialSubmitCadence(snapshot, initialCadence),
|
||||
submitCadence: resolveInitialSubmitCadence(snapshot.schedule, initialCadence),
|
||||
hosts: [...snapshot.hosts],
|
||||
projectOptions: buildProjectOptions(snapshot.defaults.projectTargets, selectedServerId),
|
||||
selectedServerId,
|
||||
|
||||
@@ -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" })),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
24
packages/cli/src/cli-surface.test.ts
Normal file
24
packages/cli/src/cli-surface.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCli } from "./cli.js";
|
||||
|
||||
describe("canonical CLI surface", () => {
|
||||
it("shows workspace and heartbeat commands while hiding worktree compatibility", () => {
|
||||
const cli = createCli();
|
||||
const help = cli.helpInformation();
|
||||
expect(help).toContain("workspace");
|
||||
expect(help).toContain("heartbeat");
|
||||
expect(help).not.toContain("worktree");
|
||||
});
|
||||
|
||||
it("hides legacy run worktree syntax", () => {
|
||||
const run = createCli().commands.find((command) => command.name() === "run");
|
||||
expect(run?.helpInformation()).toContain("--isolation <local|worktree>");
|
||||
expect(run?.helpInformation()).not.toContain("--worktree <name>");
|
||||
});
|
||||
|
||||
it("uses background for execution and reserves detach for ownership", () => {
|
||||
const run = createCli().commands.find((command) => command.name() === "run");
|
||||
expect(run?.helpInformation()).toContain("--background");
|
||||
expect(run?.helpInformation()).not.toContain("--detach");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
58
packages/cli/src/commands/agent/detach.ts
Normal file
58
packages/cli/src/commands/agent/detach.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandError,
|
||||
CommandOptions,
|
||||
OutputSchema,
|
||||
SingleResult,
|
||||
} from "../../output/index.js";
|
||||
|
||||
interface AgentDetachResult {
|
||||
agentId: string;
|
||||
status: "detached";
|
||||
}
|
||||
|
||||
const detachSchema: OutputSchema<AgentDetachResult> = {
|
||||
idField: "agentId",
|
||||
columns: [
|
||||
{ header: "AGENT ID", field: "agentId" },
|
||||
{ header: "STATUS", field: "status" },
|
||||
],
|
||||
};
|
||||
|
||||
export async function runDetachCommand(
|
||||
agentIdArg: string,
|
||||
options: CommandOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<AgentDetachResult>> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
const agentId = resolveAgentId(
|
||||
agentIdArg,
|
||||
payload.entries.map((entry) => entry.agent),
|
||||
);
|
||||
if (!agentId) {
|
||||
throw {
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: `Agent not found: ${agentIdArg}`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
await client.detachAgent(agentId);
|
||||
return { type: "single", data: { agentId, status: "detached" }, schema: detachSchema };
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { addAttachOptions, runAttachCommand } from "./attach.js";
|
||||
import { addReloadOptions, runReloadCommand } from "./reload.js";
|
||||
import { addImportOptions, runImportCommand } from "./import.js";
|
||||
import { runUpdateCommand } from "./update.js";
|
||||
import { runDetachCommand } from "./detach.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import {
|
||||
addDaemonHostOption,
|
||||
@@ -76,6 +77,13 @@ export function createAgentCommand(): Command {
|
||||
withOutput(runReloadCommand),
|
||||
);
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command("detach")
|
||||
.description("Make a subagent independent without stopping or moving it")
|
||||
.argument("<id>", "Agent ID, prefix, or name"),
|
||||
).action(withOutput(runDetachCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command("update")
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,55 +18,61 @@ import { resolveProviderAndModel } from "../../utils/provider-model.js";
|
||||
export { resolveProviderAndModel } from "../../utils/provider-model.js";
|
||||
|
||||
export function addRunOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.description("Create and start an agent with a task")
|
||||
.argument("<prompt>", "The task/prompt for the agent")
|
||||
.option("-d, --detach", "Run in background (detached)")
|
||||
.option("--title <title>", "Assign a title to the agent")
|
||||
.addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp())
|
||||
.option(
|
||||
"--provider <provider>",
|
||||
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
||||
)
|
||||
.option(
|
||||
"--model <model>",
|
||||
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
|
||||
)
|
||||
.option("--thinking <id>", "Thinking option ID to use for this run")
|
||||
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
|
||||
.option("--worktree <name>", "Create agent in a new git worktree")
|
||||
.option("--base <branch>", "Base branch for worktree (default: current branch)")
|
||||
.option(
|
||||
"--workspace <id>",
|
||||
"Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)",
|
||||
)
|
||||
.option(
|
||||
"--image <path>",
|
||||
"Attach image(s) to the initial prompt (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option("--cwd <path>", "Working directory (default: current)")
|
||||
.option(
|
||||
"--env <key=value>",
|
||||
"Set environment variable(s) for the agent process (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
"--label <key=value>",
|
||||
"Add label(s) to the agent (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
"--wait-timeout <duration>",
|
||||
"Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit",
|
||||
)
|
||||
.option(
|
||||
"--output-schema <schema>",
|
||||
"Output JSON matching the provided schema file path or inline JSON schema",
|
||||
);
|
||||
return (
|
||||
cmd
|
||||
.description("Create and start an agent with a task")
|
||||
.argument("<prompt>", "The task/prompt for the agent")
|
||||
.option("-d, --background", "Run in background")
|
||||
// COMPAT(detachRunFlag): --detach used to mean background execution, not
|
||||
// ownership transfer. Added in v0.2.0; remove after 2027-01-17.
|
||||
.addOption(new Option("--detach", "Legacy alias for --background").hideHelp())
|
||||
.option("--title <title>", "Assign a title to the agent")
|
||||
.addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp())
|
||||
.option(
|
||||
"--provider <provider>",
|
||||
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
||||
)
|
||||
.option(
|
||||
"--model <model>",
|
||||
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
|
||||
)
|
||||
.option("--thinking <id>", "Thinking option ID to use for this run")
|
||||
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
|
||||
.option("--isolation <local|worktree>", "Create a new workspace with this isolation")
|
||||
.addOption(new Option("--worktree <name>", "Legacy workspace isolation alias").hideHelp())
|
||||
.option("--base <branch>", "Base branch for an isolated workspace")
|
||||
.option(
|
||||
"--workspace <id>",
|
||||
"Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)",
|
||||
)
|
||||
.option(
|
||||
"--image <path>",
|
||||
"Attach image(s) to the initial prompt (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option("--cwd <path>", "Working directory (default: current)")
|
||||
.option(
|
||||
"--env <key=value>",
|
||||
"Set environment variable(s) for the agent process (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
"--label <key=value>",
|
||||
"Add label(s) to the agent (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
"--wait-timeout <duration>",
|
||||
"Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit",
|
||||
)
|
||||
.option(
|
||||
"--output-schema <schema>",
|
||||
"Output JSON matching the provided schema file path or inline JSON schema",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Result type for agent run command */
|
||||
@@ -91,6 +97,7 @@ export const agentRunSchema: OutputSchema<AgentRunResult> = {
|
||||
};
|
||||
|
||||
export interface AgentRunOptions extends CommandOptions {
|
||||
background?: boolean;
|
||||
detach?: boolean;
|
||||
title?: string;
|
||||
name?: string;
|
||||
@@ -98,6 +105,7 @@ export interface AgentRunOptions extends CommandOptions {
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
mode?: string;
|
||||
isolation?: string;
|
||||
worktree?: string;
|
||||
base?: string;
|
||||
workspace?: string;
|
||||
@@ -270,40 +278,54 @@ function validateRunOptions(prompt: string, options: AgentRunOptions, outputSche
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (options.base && !options.worktree) {
|
||||
const createsIsolatedWorkspace = options.isolation === "worktree" || Boolean(options.worktree);
|
||||
if (options.isolation && options.isolation !== "local" && options.isolation !== "worktree") {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--base can only be used with --worktree",
|
||||
details: "Usage: paseo agent run --worktree <name> --base <branch> <prompt>",
|
||||
message: `Unsupported workspace isolation: ${options.isolation}`,
|
||||
details: "Use --isolation local or --isolation worktree",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (options.base && !createsIsolatedWorkspace) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--base can only be used with --isolation worktree",
|
||||
details: "Usage: paseo agent run --isolation worktree --base <branch> <prompt>",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (options.isolation && options.workspace) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--isolation and --workspace cannot be combined",
|
||||
details: "Select an existing workspace or create a new one with an isolation choice",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
// COMPAT(worktreeRunFlag): --worktree implies a new worktree-isolated workspace.
|
||||
// Added in v0.2.0; remove after 2027-01-17.
|
||||
if (options.worktree && options.workspace) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--worktree and --workspace cannot be combined",
|
||||
details: "--workspace runs in an existing workspace; --worktree mints a new one",
|
||||
details: "Use --isolation worktree instead of the legacy --worktree flag",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (options.worktree && !options.workspace && process.env.PASEO_WORKSPACE_ID) {
|
||||
if (outputSchema && runsInBackground(options)) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--worktree cannot be combined with an ambient PASEO_WORKSPACE_ID",
|
||||
details:
|
||||
"PASEO_WORKSPACE_ID selects an existing workspace; --worktree mints a new one. Unset PASEO_WORKSPACE_ID to use --worktree.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (outputSchema && options.detach) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--output-schema cannot be used with --detach",
|
||||
message: "--output-schema cannot be used with --background",
|
||||
details: "Structured output requires waiting for the agent to finish",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
}
|
||||
|
||||
function runsInBackground(options: Pick<AgentRunOptions, "background" | "detach">): boolean {
|
||||
return Boolean(options.background || options.detach);
|
||||
}
|
||||
|
||||
function parseWaitTimeoutOption(waitTimeout: string | undefined): number {
|
||||
if (!waitTimeout) return 0;
|
||||
try {
|
||||
@@ -409,45 +431,79 @@ async function connectToDaemonOrThrow(
|
||||
// runs in. The CLI resolves one before creating any agent, so no run leans on
|
||||
// createAgent's legacy cwd->workspace fallback.
|
||||
interface RunWorkspace {
|
||||
id: string;
|
||||
id?: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface RunWorkspaceLookupClient {
|
||||
fetchWorkspaces(options: { filter: { query: string }; page: { limit: number } }): Promise<{
|
||||
entries: Array<{ id: string; workspaceDirectory: string }>;
|
||||
pageInfo: { nextCursor: string | null };
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function resolveExistingRunWorkspace(
|
||||
client: RunWorkspaceLookupClient,
|
||||
workspaceId: string,
|
||||
): Promise<RunWorkspace> {
|
||||
const result = await client.fetchWorkspaces({
|
||||
filter: { query: workspaceId },
|
||||
page: { limit: 200 },
|
||||
});
|
||||
const workspace = result.entries.find((entry) => entry.id === workspaceId);
|
||||
if (workspace) {
|
||||
return { id: workspace.id, cwd: workspace.workspaceDirectory };
|
||||
}
|
||||
|
||||
throw {
|
||||
code: "WORKSPACE_NOT_FOUND",
|
||||
message: `Workspace not found: ${workspaceId}`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
// Workspace policy for `paseo run`. Precedence:
|
||||
// 1. --workspace <id> -> run in that existing workspace
|
||||
// 2. $PASEO_WORKSPACE_ID -> exported by workspace terminals
|
||||
// 3. --worktree <name> -> mint a new worktree-backed workspace
|
||||
// 4. bare run -> mint a new local-backed workspace for cwd
|
||||
// --worktree is rejected alongside both --workspace and an ambient
|
||||
// $PASEO_WORKSPACE_ID (validateRunOptions), so worktree resolution here never
|
||||
// races an existing-workspace selection.
|
||||
// 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace
|
||||
// 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals
|
||||
// 4. --isolation <kind> -> mint a new workspace with explicit isolation
|
||||
// 5. bare run -> mint a new local-backed workspace for cwd
|
||||
async function resolveRunWorkspace(
|
||||
client: ConnectedDaemonClient,
|
||||
options: AgentRunOptions,
|
||||
cwd: string,
|
||||
): Promise<RunWorkspace> {
|
||||
// An explicit --worktree mints its own workspace; --workspace and an ambient
|
||||
// PASEO_WORKSPACE_ID are both rejected alongside --worktree upstream.
|
||||
const explicit = options.worktree
|
||||
? undefined
|
||||
: options.workspace?.trim() || process.env.PASEO_WORKSPACE_ID?.trim();
|
||||
const requestedIsolation = options.isolation ?? (options.worktree ? "worktree" : undefined);
|
||||
const explicit = requestedIsolation ? undefined : options.workspace?.trim();
|
||||
if (explicit) {
|
||||
console.error(`Using workspace ${explicit}`);
|
||||
return { id: explicit, cwd };
|
||||
return resolveExistingRunWorkspace(client, explicit);
|
||||
}
|
||||
|
||||
if (!requestedIsolation && resolveRunCallerAgentId()) {
|
||||
return { cwd };
|
||||
}
|
||||
|
||||
const ambientWorkspaceId = requestedIsolation
|
||||
? undefined
|
||||
: process.env.PASEO_WORKSPACE_ID?.trim();
|
||||
if (ambientWorkspaceId) {
|
||||
console.error(`Using workspace ${ambientWorkspaceId}`);
|
||||
return resolveExistingRunWorkspace(client, ambientWorkspaceId);
|
||||
}
|
||||
|
||||
// TODO: thread the run `prompt` as firstAgentContext so workspace-level
|
||||
// title/branch generation picks up the task description (U8/U6 deferred).
|
||||
const result = options.worktree
|
||||
? await client.createWorkspace({
|
||||
source: {
|
||||
kind: "worktree",
|
||||
cwd,
|
||||
worktreeSlug: options.worktree,
|
||||
baseBranch: options.base,
|
||||
},
|
||||
})
|
||||
: await client.createWorkspace({ source: { kind: "directory", path: cwd } });
|
||||
const result =
|
||||
requestedIsolation === "worktree"
|
||||
? await client.createWorkspace({
|
||||
source: {
|
||||
kind: "worktree",
|
||||
cwd,
|
||||
worktreeSlug: options.worktree,
|
||||
baseBranch: options.base,
|
||||
},
|
||||
})
|
||||
: await client.createWorkspace({ source: { kind: "directory", path: cwd } });
|
||||
|
||||
if (!result.workspace) {
|
||||
throw {
|
||||
@@ -503,6 +559,7 @@ export async function runRunCommand(
|
||||
|
||||
const workspace = await resolveRunWorkspace(client, options, cwd);
|
||||
const workspaceId = workspace.id;
|
||||
const callerAgentId = resolveRunCallerAgentId();
|
||||
const runCwd = workspace.cwd;
|
||||
|
||||
if (outputSchema) {
|
||||
@@ -514,6 +571,7 @@ export async function runRunCommand(
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd: runCwd,
|
||||
workspaceId,
|
||||
callerAgentId,
|
||||
title: resolvedTitle,
|
||||
modeId: options.mode,
|
||||
model: resolvedProviderModel.model,
|
||||
@@ -584,6 +642,7 @@ export async function runRunCommand(
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd: runCwd,
|
||||
workspaceId,
|
||||
callerAgentId,
|
||||
title: resolvedTitle,
|
||||
modeId: options.mode,
|
||||
model: resolvedProviderModel.model,
|
||||
@@ -594,8 +653,8 @@ export async function runRunCommand(
|
||||
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
||||
});
|
||||
|
||||
// Default run behavior is foreground: wait for completion unless --detach is set.
|
||||
if (!options.detach) {
|
||||
// Default run behavior is foreground: wait for completion unless background execution is set.
|
||||
if (!runsInBackground(options)) {
|
||||
const state = await client.waitForFinish(agent.id, waitTimeoutMs);
|
||||
await client.close();
|
||||
|
||||
@@ -631,3 +690,9 @@ export async function runRunCommand(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRunCallerAgentId(
|
||||
env: { PASEO_AGENT_ID?: string } = process.env,
|
||||
): string | undefined {
|
||||
return env.PASEO_AGENT_ID?.trim() || undefined;
|
||||
}
|
||||
|
||||
180
packages/cli/src/commands/heartbeat/index.ts
Normal file
180
packages/cli/src/commands/heartbeat/index.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Command } from "commander";
|
||||
import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
import { parseDuration } from "../../utils/duration.js";
|
||||
import {
|
||||
connectScheduleClient,
|
||||
toScheduleCommandError,
|
||||
toScheduleRow,
|
||||
type ScheduleRow,
|
||||
} from "../schedule/shared.js";
|
||||
import { scheduleSchema } from "../schedule/schema.js";
|
||||
|
||||
interface HeartbeatOptions extends CommandOptions {
|
||||
cron?: string;
|
||||
timezone?: string;
|
||||
name?: string;
|
||||
maxRuns?: string;
|
||||
expiresIn?: string;
|
||||
}
|
||||
|
||||
interface HeartbeatDeleteRow {
|
||||
id: string;
|
||||
status: "deleted";
|
||||
}
|
||||
|
||||
const heartbeatDeleteSchema: OutputSchema<HeartbeatDeleteRow> = {
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: "ID", field: "id" },
|
||||
{ header: "STATUS", field: "status" },
|
||||
],
|
||||
};
|
||||
|
||||
function requireCallerAgentId(): string {
|
||||
const agentId = process.env.PASEO_AGENT_ID?.trim();
|
||||
if (!agentId) {
|
||||
throw new Error("Heartbeat commands must run inside a Paseo agent");
|
||||
}
|
||||
return agentId;
|
||||
}
|
||||
|
||||
async function requireOwnedHeartbeat(
|
||||
client: Awaited<ReturnType<typeof connectScheduleClient>>["client"],
|
||||
id: string,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
const payload = await client.scheduleInspect({ id });
|
||||
if (payload.error || !payload.schedule) {
|
||||
throw new Error(payload.error ?? `Heartbeat not found: ${id}`);
|
||||
}
|
||||
if (payload.schedule.target.type !== "agent" || payload.schedule.target.agentId !== agentId) {
|
||||
throw new Error(`Heartbeat ${id} does not belong to agent ${agentId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCreateHeartbeat(
|
||||
prompt: string,
|
||||
options: HeartbeatOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ScheduleRow>> {
|
||||
const agentId = requireCallerAgentId();
|
||||
const cron = options.cron?.trim();
|
||||
if (!cron) {
|
||||
throw new Error("--cron is required");
|
||||
}
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
try {
|
||||
const maxRuns = options.maxRuns ? Number.parseInt(options.maxRuns, 10) : undefined;
|
||||
if (maxRuns !== undefined && (!Number.isSafeInteger(maxRuns) || maxRuns <= 0)) {
|
||||
throw new Error("--max-runs must be a positive integer");
|
||||
}
|
||||
const payload = await client.scheduleCreate({
|
||||
prompt: prompt.trim(),
|
||||
cadence: {
|
||||
type: "cron",
|
||||
expression: cron,
|
||||
...(options.timezone?.trim() ? { timezone: options.timezone.trim() } : {}),
|
||||
},
|
||||
target: { type: "agent", agentId },
|
||||
...(options.name?.trim() ? { name: options.name.trim() } : {}),
|
||||
...(maxRuns ? { maxRuns } : {}),
|
||||
...(options.expiresIn
|
||||
? { expiresAt: new Date(Date.now() + parseDuration(options.expiresIn)).toISOString() }
|
||||
: {}),
|
||||
});
|
||||
if (payload.error || !payload.schedule) {
|
||||
throw new Error(payload.error ?? "Heartbeat creation failed");
|
||||
}
|
||||
return { type: "single", data: toScheduleRow(payload.schedule), schema: scheduleSchema };
|
||||
} catch (error) {
|
||||
throw toScheduleCommandError("HEARTBEAT_CREATE_FAILED", "create heartbeat", error);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function runUpdateHeartbeat(
|
||||
id: string,
|
||||
options: HeartbeatOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ScheduleRow>> {
|
||||
const agentId = requireCallerAgentId();
|
||||
const cron = options.cron?.trim();
|
||||
if (!cron) {
|
||||
throw new Error("--cron is required");
|
||||
}
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
try {
|
||||
await requireOwnedHeartbeat(client, id, agentId);
|
||||
const payload = await client.scheduleUpdate({
|
||||
id,
|
||||
cadence: {
|
||||
type: "cron",
|
||||
expression: cron,
|
||||
...(options.timezone?.trim() ? { timezone: options.timezone.trim() } : {}),
|
||||
},
|
||||
});
|
||||
if (payload.error || !payload.schedule) {
|
||||
throw new Error(payload.error ?? `Heartbeat update failed: ${id}`);
|
||||
}
|
||||
return { type: "single", data: toScheduleRow(payload.schedule), schema: scheduleSchema };
|
||||
} catch (error) {
|
||||
throw toScheduleCommandError("HEARTBEAT_UPDATE_FAILED", "update heartbeat", error);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function runDeleteHeartbeat(
|
||||
id: string,
|
||||
options: HeartbeatOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<HeartbeatDeleteRow>> {
|
||||
const agentId = requireCallerAgentId();
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
try {
|
||||
await requireOwnedHeartbeat(client, id, agentId);
|
||||
const payload = await client.scheduleDelete({ id });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return {
|
||||
type: "single",
|
||||
data: { id: payload.scheduleId, status: "deleted" },
|
||||
schema: heartbeatDeleteSchema,
|
||||
};
|
||||
} catch (error) {
|
||||
throw toScheduleCommandError("HEARTBEAT_DELETE_FAILED", "delete heartbeat", error);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export function createHeartbeatCommand(): Command {
|
||||
const heartbeat = new Command("heartbeat").description("Manage this agent's heartbeats");
|
||||
addJsonAndDaemonHostOptions(
|
||||
heartbeat
|
||||
.command("create")
|
||||
.description("Create a recurring prompt for this agent")
|
||||
.argument("<prompt>", "Prompt to send")
|
||||
.requiredOption("--cron <expr>", "Five-field cron cadence")
|
||||
.option("--timezone <iana>", "IANA time zone")
|
||||
.option("--name <name>", "Heartbeat name")
|
||||
.option("--max-runs <n>", "Maximum number of runs")
|
||||
.option("--expires-in <duration>", "Time to live"),
|
||||
).action(withOutput(runCreateHeartbeat));
|
||||
addJsonAndDaemonHostOptions(
|
||||
heartbeat
|
||||
.command("update")
|
||||
.description("Change a heartbeat cron cadence")
|
||||
.argument("<id>", "Heartbeat ID")
|
||||
.requiredOption("--cron <expr>", "Five-field cron cadence")
|
||||
.option("--timezone <iana>", "IANA time zone"),
|
||||
).action(withOutput(runUpdateHeartbeat));
|
||||
addJsonAndDaemonHostOptions(
|
||||
heartbeat.command("delete").description("Delete a heartbeat").argument("<id>", "Heartbeat ID"),
|
||||
).action(withOutput(runDeleteHeartbeat));
|
||||
return heartbeat;
|
||||
}
|
||||
@@ -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)")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
52
packages/cli/src/commands/workspace/archive.ts
Normal file
52
packages/cli/src/commands/workspace/archive.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js";
|
||||
|
||||
interface WorkspaceArchiveResult {
|
||||
workspaceId: string;
|
||||
status: "archived";
|
||||
archivedAt: string;
|
||||
}
|
||||
|
||||
const workspaceArchiveSchema: OutputSchema<WorkspaceArchiveResult> = {
|
||||
idField: "workspaceId",
|
||||
columns: [
|
||||
{ header: "WORKSPACE ID", field: "workspaceId", width: 20 },
|
||||
{ header: "STATUS", field: "status", width: 10 },
|
||||
{ header: "ARCHIVED AT", field: "archivedAt", width: 26 },
|
||||
],
|
||||
};
|
||||
|
||||
export async function runArchiveCommand(
|
||||
workspaceId: string,
|
||||
options: { host?: string },
|
||||
_command: Command,
|
||||
): Promise<SingleResult<WorkspaceArchiveResult>> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
} satisfies CommandError;
|
||||
});
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
if (!payload.archivedAt) {
|
||||
throw new Error("Workspace archive did not return an archive timestamp");
|
||||
}
|
||||
return {
|
||||
type: "single",
|
||||
data: { workspaceId, status: "archived", archivedAt: payload.archivedAt },
|
||||
schema: workspaceArchiveSchema,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw { code: "WORKSPACE_ARCHIVE_FAILED", message } satisfies CommandError;
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
125
packages/cli/src/commands/workspace/create.test.ts
Normal file
125
packages/cli/src/commands/workspace/create.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildWorkspaceSource } from "./create.js";
|
||||
|
||||
describe("workspace create source", () => {
|
||||
it("maps local isolation to a directory workspace", () => {
|
||||
expect(
|
||||
buildWorkspaceSource({ isolation: "local", path: "/tmp/project", project: "project-1" }),
|
||||
).toEqual({ kind: "directory", path: "/tmp/project", projectId: "project-1" });
|
||||
});
|
||||
|
||||
it("keeps branch names separate from managed worktree slugs", () => {
|
||||
expect(
|
||||
buildWorkspaceSource({
|
||||
isolation: "worktree",
|
||||
path: "/tmp/project",
|
||||
mode: "branch-off",
|
||||
newBranch: "feature/auth",
|
||||
worktreeSlug: "feature-auth",
|
||||
base: "main",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "worktree",
|
||||
cwd: "/tmp/project",
|
||||
action: "branch-off",
|
||||
branchName: "feature/auth",
|
||||
worktreeSlug: "feature-auth",
|
||||
baseBranch: "main",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a project as the worktree source without capturing the ambient directory", () => {
|
||||
expect(
|
||||
buildWorkspaceSource({
|
||||
isolation: "worktree",
|
||||
project: "project-1",
|
||||
mode: "branch-off",
|
||||
newBranch: "fix-x",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "worktree",
|
||||
projectId: "project-1",
|
||||
action: "branch-off",
|
||||
branchName: "fix-x",
|
||||
});
|
||||
});
|
||||
|
||||
it("checks out an existing branch into a worktree workspace", () => {
|
||||
expect(
|
||||
buildWorkspaceSource({
|
||||
isolation: "worktree",
|
||||
path: "/tmp/project",
|
||||
mode: "checkout-branch",
|
||||
branch: "existing-work",
|
||||
worktreeSlug: "existing-work-copy",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "worktree",
|
||||
cwd: "/tmp/project",
|
||||
action: "checkout",
|
||||
refName: "existing-work",
|
||||
worktreeSlug: "existing-work-copy",
|
||||
});
|
||||
});
|
||||
|
||||
it("checks out a pull request into a worktree workspace", () => {
|
||||
expect(
|
||||
buildWorkspaceSource({
|
||||
isolation: "worktree",
|
||||
path: "/tmp/project",
|
||||
mode: "checkout-pr",
|
||||
prNumber: "42",
|
||||
forge: "gitlab",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "worktree",
|
||||
cwd: "/tmp/project",
|
||||
action: "checkout",
|
||||
checkoutSource: {
|
||||
kind: "change_request",
|
||||
forge: "gitlab",
|
||||
number: 42,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lets the source checkout select the forge when it is omitted", () => {
|
||||
expect(
|
||||
buildWorkspaceSource({
|
||||
isolation: "worktree",
|
||||
path: "/tmp/project",
|
||||
mode: "checkout-pr",
|
||||
prNumber: "42",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "worktree",
|
||||
cwd: "/tmp/project",
|
||||
action: "checkout",
|
||||
checkoutSource: {
|
||||
kind: "change_request",
|
||||
number: 42,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("requires the mode-specific checkout target", () => {
|
||||
expect(() => buildWorkspaceSource({ isolation: "worktree", mode: "checkout-branch" })).toThrow(
|
||||
"--branch is required",
|
||||
);
|
||||
expect(() => buildWorkspaceSource({ isolation: "worktree", mode: "checkout-pr" })).toThrow(
|
||||
"--pr-number is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects worktree options for local isolation", () => {
|
||||
expect(() => buildWorkspaceSource({ isolation: "local", mode: "branch-off" })).toThrow(
|
||||
"Worktree options require --isolation worktree",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown isolation", () => {
|
||||
expect(() => buildWorkspaceSource({ isolation: "container" })).toThrow(
|
||||
"Unsupported workspace isolation",
|
||||
);
|
||||
});
|
||||
});
|
||||
161
packages/cli/src/commands/workspace/create.ts
Normal file
161
packages/cli/src/commands/workspace/create.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js";
|
||||
import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js";
|
||||
|
||||
export interface WorkspaceCreateOptions extends CommandOptions {
|
||||
isolation?: string;
|
||||
path?: string;
|
||||
project?: string;
|
||||
title?: string;
|
||||
mode?: string;
|
||||
worktreeSlug?: string;
|
||||
newBranch?: string;
|
||||
base?: string;
|
||||
branch?: string;
|
||||
prNumber?: string;
|
||||
forge?: string;
|
||||
}
|
||||
|
||||
interface WorktreeSourceBase {
|
||||
kind: "worktree";
|
||||
cwd?: string;
|
||||
projectId?: string;
|
||||
worktreeSlug?: string;
|
||||
}
|
||||
|
||||
function assertOptionsAbsent(values: unknown[], message: string): void {
|
||||
if (values.some((value) => value !== undefined)) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function buildLocalWorkspaceSource(options: WorkspaceCreateOptions, path: string) {
|
||||
assertOptionsAbsent(
|
||||
[
|
||||
options.mode,
|
||||
options.worktreeSlug,
|
||||
options.newBranch,
|
||||
options.base,
|
||||
options.branch,
|
||||
options.prNumber,
|
||||
options.forge,
|
||||
],
|
||||
"Worktree options require --isolation worktree",
|
||||
);
|
||||
return {
|
||||
kind: "directory" as const,
|
||||
path,
|
||||
...(options.project ? { projectId: options.project } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBranchOffSource(options: WorkspaceCreateOptions, source: WorktreeSourceBase) {
|
||||
assertOptionsAbsent(
|
||||
[options.branch, options.prNumber, options.forge],
|
||||
"--branch, --pr-number, and --forge require a checkout mode",
|
||||
);
|
||||
return {
|
||||
...source,
|
||||
action: "branch-off" as const,
|
||||
...(options.newBranch ? { branchName: options.newBranch } : {}),
|
||||
...(options.base ? { baseBranch: options.base } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBranchCheckoutSource(options: WorkspaceCreateOptions, source: WorktreeSourceBase) {
|
||||
if (!options.branch) {
|
||||
throw new Error("--branch is required for --mode checkout-branch");
|
||||
}
|
||||
assertOptionsAbsent(
|
||||
[options.newBranch, options.base, options.prNumber, options.forge],
|
||||
"--new-branch, --base, --pr-number, and --forge are not valid for --mode checkout-branch",
|
||||
);
|
||||
return { ...source, action: "checkout" as const, refName: options.branch };
|
||||
}
|
||||
|
||||
function buildPullRequestCheckoutSource(
|
||||
options: WorkspaceCreateOptions,
|
||||
source: WorktreeSourceBase,
|
||||
) {
|
||||
if (options.prNumber === undefined || options.prNumber === "") {
|
||||
throw new Error("--pr-number is required for --mode checkout-pr");
|
||||
}
|
||||
const prNumber = Number(options.prNumber);
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||
throw new Error("--pr-number must be a positive integer");
|
||||
}
|
||||
assertOptionsAbsent(
|
||||
[options.newBranch, options.base, options.branch],
|
||||
"--new-branch, --base, and --branch are not valid for --mode checkout-pr",
|
||||
);
|
||||
return {
|
||||
...source,
|
||||
action: "checkout" as const,
|
||||
checkoutSource: {
|
||||
kind: "change_request" as const,
|
||||
...(options.forge ? { forge: options.forge } : {}),
|
||||
number: prNumber,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorktreeWorkspaceSource(options: WorkspaceCreateOptions, path: string | undefined) {
|
||||
const source: WorktreeSourceBase = {
|
||||
kind: "worktree",
|
||||
...(path ? { cwd: path } : {}),
|
||||
...(options.project ? { projectId: options.project } : {}),
|
||||
...(options.worktreeSlug ? { worktreeSlug: options.worktreeSlug } : {}),
|
||||
};
|
||||
switch (options.mode ?? "branch-off") {
|
||||
case "branch-off":
|
||||
return buildBranchOffSource(options, source);
|
||||
case "checkout-branch":
|
||||
return buildBranchCheckoutSource(options, source);
|
||||
case "checkout-pr":
|
||||
return buildPullRequestCheckoutSource(options, source);
|
||||
default:
|
||||
throw new Error(`Unsupported worktree mode: ${String(options.mode)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWorkspaceSource(options: WorkspaceCreateOptions) {
|
||||
if (options.isolation === "local") {
|
||||
return buildLocalWorkspaceSource(options, options.path ?? process.cwd());
|
||||
}
|
||||
if (options.isolation === "worktree") {
|
||||
const sourcePath = options.path ?? (options.project ? undefined : process.cwd());
|
||||
return buildWorktreeWorkspaceSource(options, sourcePath);
|
||||
}
|
||||
throw new Error(`Unsupported workspace isolation: ${String(options.isolation)}`);
|
||||
}
|
||||
|
||||
export async function runCreateCommand(
|
||||
options: WorkspaceCreateOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<WorkspaceRow>> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
} satisfies CommandError;
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = await client.createWorkspace({
|
||||
source: buildWorkspaceSource(options),
|
||||
...(options.title ? { title: options.title } : {}),
|
||||
});
|
||||
if (!payload.workspace) {
|
||||
throw new Error(payload.error ?? "Workspace creation failed");
|
||||
}
|
||||
return { type: "single", data: toWorkspaceRow(payload.workspace), schema: workspaceSchema };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw { code: "WORKSPACE_CREATE_FAILED", message } satisfies CommandError;
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
43
packages/cli/src/commands/workspace/index.ts
Normal file
43
packages/cli/src/commands/workspace/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Command } from "commander";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
import { runArchiveCommand } from "./archive.js";
|
||||
import { runCreateCommand } from "./create.js";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
|
||||
export function createWorkspaceCommand(): Command {
|
||||
const workspace = new Command("workspace").description("Manage workspaces");
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
workspace
|
||||
.command("create")
|
||||
.description("Create a workspace")
|
||||
.requiredOption("--isolation <local|worktree>", "Workspace isolation")
|
||||
.option("--path <path>", "Local directory or source checkout (default: current)")
|
||||
.option("--project <id>", "Existing project id")
|
||||
.option("--title <title>", "Workspace title")
|
||||
.option(
|
||||
"--mode <mode>",
|
||||
"Worktree mode: branch-off, checkout-branch, or checkout-pr (default: branch-off)",
|
||||
)
|
||||
.option("--worktree-slug <slug>", "Managed worktree path slug")
|
||||
.option("--new-branch <name>", "New branch name (--mode branch-off)")
|
||||
.option("--base <ref>", "Base ref (--mode branch-off)")
|
||||
.option("--branch <name>", "Existing branch (--mode checkout-branch)")
|
||||
.option("--pr-number <n>", "Pull request or change request number (--mode checkout-pr)")
|
||||
.option("--forge <forge>", "Forge for --mode checkout-pr (default: source checkout)"),
|
||||
).action(withOutput(runCreateCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(workspace.command("ls").description("List active workspaces")).action(
|
||||
withOutput(runLsCommand),
|
||||
);
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
workspace
|
||||
.command("archive")
|
||||
.description("Archive a workspace and everything it owns")
|
||||
.argument("<workspace-id>", "Workspace id"),
|
||||
).action(withOutput(runArchiveCommand));
|
||||
|
||||
return workspace;
|
||||
}
|
||||
32
packages/cli/src/commands/workspace/ls.ts
Normal file
32
packages/cli/src/commands/workspace/ls.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandError, ListResult } from "../../output/index.js";
|
||||
import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js";
|
||||
|
||||
export async function runLsCommand(
|
||||
options: { host?: string },
|
||||
_command: Command,
|
||||
): Promise<ListResult<WorkspaceRow>> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
} satisfies CommandError;
|
||||
});
|
||||
try {
|
||||
const workspaces: WorkspaceRow[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const payload = await client.fetchWorkspaces({
|
||||
page: { limit: 200, ...(cursor ? { cursor } : {}) },
|
||||
});
|
||||
workspaces.push(...payload.entries.map(toWorkspaceRow));
|
||||
cursor = payload.pageInfo.nextCursor ?? undefined;
|
||||
} while (cursor);
|
||||
return { type: "list", data: workspaces, schema: workspaceSchema };
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
31
packages/cli/src/commands/workspace/shared.ts
Normal file
31
packages/cli/src/commands/workspace/shared.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import type { OutputSchema } from "../../output/index.js";
|
||||
|
||||
export interface WorkspaceRow {
|
||||
workspaceId: string;
|
||||
project: string;
|
||||
name: string;
|
||||
isolation: "local" | "worktree";
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export const workspaceSchema: OutputSchema<WorkspaceRow> = {
|
||||
idField: "workspaceId",
|
||||
columns: [
|
||||
{ header: "WORKSPACE ID", field: "workspaceId", width: 20 },
|
||||
{ header: "PROJECT", field: "project", width: 20 },
|
||||
{ header: "NAME", field: "name", width: 22 },
|
||||
{ header: "ISOLATION", field: "isolation", width: 10 },
|
||||
{ header: "CWD", field: "cwd", width: 42 },
|
||||
],
|
||||
};
|
||||
|
||||
export function toWorkspaceRow(workspace: WorkspaceDescriptorPayload): WorkspaceRow {
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
project: workspace.projectDisplayName,
|
||||
name: workspace.name,
|
||||
isolation: workspace.workspaceKind === "worktree" ? "worktree" : "local",
|
||||
cwd: workspace.workspaceDirectory,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
21
packages/protocol/src/schedule/cadence.test.ts
Normal file
21
packages/protocol/src/schedule/cadence.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { everyMsToFiveFieldCron } from "./cadence.js";
|
||||
|
||||
describe("everyMsToFiveFieldCron", () => {
|
||||
it.each([
|
||||
[60_000, "*/1 * * * *"],
|
||||
[15 * 60_000, "*/15 * * * *"],
|
||||
[60 * 60_000, "0 * * * *"],
|
||||
[6 * 60 * 60_000, "0 */6 * * *"],
|
||||
[24 * 60 * 60_000, "0 0 * * *"],
|
||||
])("converts %i milliseconds", (everyMs, cron) => {
|
||||
expect(everyMsToFiveFieldCron(everyMs)).toBe(cron);
|
||||
});
|
||||
|
||||
it.each([30_000, 7 * 60_000, 5 * 60 * 60_000, 48 * 60 * 60_000])(
|
||||
"rejects unrepresentable interval %i",
|
||||
(everyMs) => {
|
||||
expect(everyMsToFiveFieldCron(everyMs)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
27
packages/protocol/src/schedule/cadence.ts
Normal file
27
packages/protocol/src/schedule/cadence.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Convert an exact rolling interval to an equivalent five-field cron cadence.
|
||||
* Returns null when cron's calendar boundaries would change the interval.
|
||||
*/
|
||||
export function everyMsToFiveFieldCron(everyMs: number): string | null {
|
||||
const minutes = everyMs / 60_000;
|
||||
if (!Number.isInteger(minutes) || minutes <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (minutes < 60 && 60 % minutes === 0) {
|
||||
return `*/${minutes} * * * *`;
|
||||
}
|
||||
if (minutes === 60) {
|
||||
return "0 * * * *";
|
||||
}
|
||||
if (minutes % 60 !== 0) {
|
||||
return null;
|
||||
}
|
||||
const hours = minutes / 60;
|
||||
if (hours < 24 && 24 % hours === 0) {
|
||||
return `0 */${hours} * * *`;
|
||||
}
|
||||
if (hours === 24) {
|
||||
return "0 0 * * *";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
74
packages/server/src/server/agent/create-agent/intent.test.ts
Normal file
74
packages/server/src/server/agent/create-agent/intent.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { resolveCreateAgentIntent } from "./intent.js";
|
||||
|
||||
describe("resolveCreateAgentIntent", () => {
|
||||
it("keeps caller parentage when an explicit workspace changes placement", async () => {
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
explicitWorkspaceId: "workspace-isolated",
|
||||
caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" },
|
||||
labels: { purpose: "review" },
|
||||
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/isolated" }),
|
||||
createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }),
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
workspaceId: "workspace-isolated",
|
||||
cwd: "/isolated",
|
||||
parentAgentId: "parent-agent",
|
||||
labels: {
|
||||
purpose: "review",
|
||||
[PARENT_AGENT_ID_LABEL]: "parent-agent",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults an agent caller to its workspace without creating one", async () => {
|
||||
let createCount = 0;
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" },
|
||||
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }),
|
||||
createWorkspace: async () => {
|
||||
createCount += 1;
|
||||
return { workspaceId: "workspace-created", cwd: "/created" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(intent.workspaceId).toBe("workspace-parent");
|
||||
expect(intent.cwd).toBe("/parent");
|
||||
expect(intent.parentAgentId).toBe("parent-agent");
|
||||
expect(createCount).toBe(0);
|
||||
});
|
||||
|
||||
it("creates a workspace for a human caller with no workspace context", async () => {
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
caller: null,
|
||||
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }),
|
||||
createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }),
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
workspaceId: "workspace-created",
|
||||
cwd: "/created",
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy detached creation independent", async () => {
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" },
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: "spoofed-parent" },
|
||||
legacyDetached: true,
|
||||
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }),
|
||||
createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }),
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
workspaceId: "workspace-parent",
|
||||
cwd: "/parent",
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
64
packages/server/src/server/agent/create-agent/intent.ts
Normal file
64
packages/server/src/server/agent/create-agent/intent.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
|
||||
export interface CreateAgentCaller {
|
||||
id: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentPlacement {
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentIntent {
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
parentAgentId: string | null;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function resolveCreateAgentIntent(input: {
|
||||
explicitWorkspaceId?: string;
|
||||
caller: CreateAgentCaller | null;
|
||||
labels?: Record<string, string>;
|
||||
childAgentDefaultLabels?: Record<string, string>;
|
||||
resolveWorkspace: (workspaceId: string) => Promise<CreateAgentPlacement>;
|
||||
createWorkspace: () => Promise<CreateAgentPlacement>;
|
||||
legacyDetached?: boolean;
|
||||
}): Promise<CreateAgentIntent> {
|
||||
const parentAgentId = input.legacyDetached ? null : (input.caller?.id ?? null);
|
||||
const placement = await resolvePlacement(input);
|
||||
const labels = {
|
||||
...input.childAgentDefaultLabels,
|
||||
...input.labels,
|
||||
...(parentAgentId ? { [PARENT_AGENT_ID_LABEL]: parentAgentId } : {}),
|
||||
};
|
||||
|
||||
// COMPAT(detachedCreate): legacy callers may still request detached creation.
|
||||
// Added in v0.2.0; remove after 2027-01-17 once detached creation is outside the floor.
|
||||
// The delete also strips a parent label injected through input.labels.
|
||||
if (input.legacyDetached) {
|
||||
delete labels[PARENT_AGENT_ID_LABEL];
|
||||
}
|
||||
|
||||
return { ...placement, parentAgentId, labels };
|
||||
}
|
||||
|
||||
async function resolvePlacement(input: {
|
||||
explicitWorkspaceId?: string;
|
||||
caller: CreateAgentCaller | null;
|
||||
resolveWorkspace: (workspaceId: string) => Promise<CreateAgentPlacement>;
|
||||
createWorkspace: () => Promise<CreateAgentPlacement>;
|
||||
}): Promise<CreateAgentPlacement> {
|
||||
if (input.explicitWorkspaceId) {
|
||||
return input.resolveWorkspace(input.explicitWorkspaceId);
|
||||
}
|
||||
if (input.caller) {
|
||||
if (!input.caller.workspaceId) {
|
||||
throw new Error(`Caller agent ${input.caller.id} has no workspace`);
|
||||
}
|
||||
return { workspaceId: input.caller.workspaceId, cwd: input.caller.cwd };
|
||||
}
|
||||
return input.createWorkspace();
|
||||
}
|
||||
@@ -1202,6 +1202,42 @@ describe("create_agent MCP tool", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("creates a fresh local workspace for canonical top-level creation", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "top-level-agent",
|
||||
provider: "codex",
|
||||
cwd: existingCwd,
|
||||
workspaceId: "workspace-created",
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Top-level agent" },
|
||||
} as ManagedAgent);
|
||||
const ensureWorkspace = vi.fn(async () => "workspace-created");
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
ensureWorkspaceForCreate: ensureWorkspace,
|
||||
logger,
|
||||
});
|
||||
|
||||
await registeredTool(server, "create_agent").handler({
|
||||
title: "Top-level agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
background: true,
|
||||
});
|
||||
|
||||
expect(ensureWorkspace).toHaveBeenCalledWith(existingCwd, { prompt: "Do work" });
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: existingCwd }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects partial explicit workspace shape", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -1508,7 +1544,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts each create_worktree target kind", async () => {
|
||||
it("exposes workspace tools instead of worktree tools", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
@@ -1516,31 +1552,14 @@ describe("create_agent MCP tool", () => {
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_worktree");
|
||||
|
||||
for (const target of [
|
||||
{ kind: "branch-off", worktreeSlug: "feature-x", baseBranch: "main" },
|
||||
{ kind: "branch-off" },
|
||||
{ kind: "checkout-branch", branch: "head-ref" },
|
||||
{ kind: "checkout-pr", githubPrNumber: 42 },
|
||||
] as const) {
|
||||
const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, target });
|
||||
expect(parsed.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects create_worktree without a target", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_worktree");
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync({});
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(lookupTool(server, "create_workspace")).toBeDefined();
|
||||
expect(lookupTool(server, "list_workspaces")).toBeDefined();
|
||||
expect(lookupTool(server, "archive_workspace")).toBeDefined();
|
||||
expect(lookupTool(server, "create_worktree")).toBeUndefined();
|
||||
expect(lookupTool(server, "list_worktrees")).toBeUndefined();
|
||||
expect(lookupTool(server, "archive_worktree")).toBeUndefined();
|
||||
expect(lookupTool(server, "detach_agent")).toBeUndefined();
|
||||
expect(lookupTool(server, "update_heartbeat")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces createAgent validation failures", async () => {
|
||||
@@ -2443,7 +2462,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers and broadcasts a workspace when create_worktree creates a worktree", async () => {
|
||||
it("creates a worktree-isolated workspace", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-"));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
@@ -2488,25 +2507,19 @@ describe("create_agent MCP tool", () => {
|
||||
>,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_worktree");
|
||||
const tool = registeredTool(server, "create_workspace");
|
||||
const response = await tool.handler({
|
||||
cwd: repoDir,
|
||||
target: {
|
||||
kind: "branch-off",
|
||||
worktreeSlug: "tool-worktree",
|
||||
branchName: "feature/tool-worktree",
|
||||
baseBranch: "main",
|
||||
},
|
||||
isolation: "worktree",
|
||||
path: repoDir,
|
||||
worktreeSlug: "tool-worktree",
|
||||
branchName: "feature/tool-worktree",
|
||||
baseBranch: "main",
|
||||
});
|
||||
|
||||
expect(response.structuredContent.branchName).toBe("feature/tool-worktree");
|
||||
expect(response.structuredContent.worktreePath).toContain("tool-worktree");
|
||||
expect(response.structuredContent.isolation).toBe("worktree");
|
||||
expect(response.structuredContent.cwd).toContain("tool-worktree");
|
||||
expect(response.structuredContent.workspaceId).toBe(broadcasts[0]);
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
|
||||
force: true,
|
||||
reason: "mcp:create-worktree",
|
||||
});
|
||||
expect(setupContinuations).toEqual([undefined]);
|
||||
expect(broadcasts).toHaveLength(1);
|
||||
expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
|
||||
@@ -2515,7 +2528,149 @@ describe("create_agent MCP tool", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
|
||||
it("creates a worktree workspace from a project root without a path", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "project-source",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "source",
|
||||
createdAt: "2026-07-18T00:00:00.000Z",
|
||||
updatedAt: "2026-07-18T00:00:00.000Z",
|
||||
});
|
||||
const receivedInputs: CreatePaseoWorktreeInput[] = [];
|
||||
const createPaseoWorktree: CreatePaseoWorktreeWorkflowFn = async (input) => {
|
||||
receivedInputs.push(input);
|
||||
return {
|
||||
worktree: { branchName: "project-worktree", worktreePath: TARGET_CWD },
|
||||
intent: { kind: "branch-off", branchName: "project-worktree", baseBranch: "main" },
|
||||
workspace: createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-project-source",
|
||||
projectId: project.projectId,
|
||||
cwd: TARGET_CWD,
|
||||
kind: "worktree",
|
||||
displayName: "project-worktree",
|
||||
title: input.title ?? null,
|
||||
createdAt: "2026-07-18T00:00:00.000Z",
|
||||
updatedAt: "2026-07-18T00:00:00.000Z",
|
||||
}),
|
||||
repoRoot: REPO_CWD,
|
||||
created: true,
|
||||
};
|
||||
};
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
projectRegistry: {
|
||||
get: async (projectId) => (projectId === project.projectId ? project : null),
|
||||
},
|
||||
createPaseoWorktree,
|
||||
logger,
|
||||
});
|
||||
|
||||
const response = await invokeToolWithParsedInput(registeredTool(server, "create_workspace"), {
|
||||
isolation: "worktree",
|
||||
projectId: project.projectId,
|
||||
worktreeSlug: "project-worktree",
|
||||
title: "Project workspace",
|
||||
});
|
||||
|
||||
expect(response.structuredContent.workspaceId).toBe("ws-project-source");
|
||||
expect(receivedInputs).toEqual([
|
||||
expect.objectContaining({
|
||||
cwd: REPO_CWD,
|
||||
projectId: project.projectId,
|
||||
title: "Project workspace",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves branch checkout and pull request checkout workspace modes", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const createPaseoWorktree = vi.fn(async (input: CreatePaseoWorktreeInput) => ({
|
||||
worktree: {
|
||||
branchName: input.refName ?? "pr-42",
|
||||
worktreePath: "/tmp/worktrees/selected",
|
||||
},
|
||||
intent: {
|
||||
kind: "checkout-branch" as const,
|
||||
branchName: input.refName ?? "pr-42",
|
||||
},
|
||||
workspace: createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-selected",
|
||||
projectId: "project-1",
|
||||
cwd: "/tmp/worktrees/selected",
|
||||
kind: "worktree",
|
||||
displayName: "selected",
|
||||
createdAt: "2026-07-18T00:00:00.000Z",
|
||||
updatedAt: "2026-07-18T00:00:00.000Z",
|
||||
}),
|
||||
repoRoot: REPO_CWD,
|
||||
created: true,
|
||||
}));
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
createPaseoWorktree,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_workspace");
|
||||
|
||||
await invokeToolWithParsedInput(tool, {
|
||||
isolation: "worktree",
|
||||
path: REPO_CWD,
|
||||
mode: "checkout-branch",
|
||||
branch: "existing-work",
|
||||
worktreeSlug: "existing-work-copy",
|
||||
});
|
||||
await invokeToolWithParsedInput(tool, {
|
||||
isolation: "worktree",
|
||||
path: REPO_CWD,
|
||||
mode: "checkout-pr",
|
||||
prNumber: 42,
|
||||
forge: "gitlab",
|
||||
});
|
||||
await invokeToolWithParsedInput(tool, {
|
||||
isolation: "worktree",
|
||||
path: REPO_CWD,
|
||||
mode: "checkout-pr",
|
||||
prNumber: 43,
|
||||
});
|
||||
|
||||
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
action: "checkout",
|
||||
refName: "existing-work",
|
||||
worktreeSlug: "existing-work-copy",
|
||||
}),
|
||||
);
|
||||
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
action: "checkout",
|
||||
checkoutSource: {
|
||||
kind: "change_request",
|
||||
forge: "gitlab",
|
||||
number: 42,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
action: "checkout",
|
||||
checkoutSource: {
|
||||
kind: "change_request",
|
||||
number: 43,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("archives a worktree-isolated workspace by workspace id", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const tempDir = realpathSync.native(
|
||||
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")),
|
||||
@@ -2568,13 +2723,15 @@ describe("create_agent MCP tool", () => {
|
||||
github: createGitHubServiceStub(),
|
||||
logger,
|
||||
});
|
||||
const createTool = registeredTool(server, "create_worktree");
|
||||
const archiveTool = registeredTool(server, "archive_worktree");
|
||||
const createTool = registeredTool(server, "create_workspace");
|
||||
const archiveTool = registeredTool(server, "archive_workspace");
|
||||
const created = await createTool.handler({
|
||||
cwd: repoDir,
|
||||
target: { kind: "branch-off", worktreeSlug: "archive-tool-worktree", baseBranch: "main" },
|
||||
isolation: "worktree",
|
||||
path: repoDir,
|
||||
worktreeSlug: "archive-tool-worktree",
|
||||
baseBranch: "main",
|
||||
});
|
||||
const createdWorktreePath = z.string().parse(created.structuredContent.worktreePath);
|
||||
const createdWorktreePath = z.string().parse(created.structuredContent.cwd);
|
||||
listActiveWorkspaces.mockImplementation(async () => [
|
||||
{ workspaceId: "ws-archive-tool-worktree", cwd: createdWorktreePath, kind: "worktree" },
|
||||
]);
|
||||
@@ -2584,19 +2741,13 @@ describe("create_agent MCP tool", () => {
|
||||
workspaceGitService.getSnapshot.mockClear();
|
||||
|
||||
await archiveTool.handler({
|
||||
cwd: repoDir,
|
||||
worktreePath: created.structuredContent.worktreePath,
|
||||
workspaceId: "ws-archive-tool-worktree",
|
||||
});
|
||||
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
|
||||
force: true,
|
||||
reason: "archive-worktree",
|
||||
});
|
||||
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir);
|
||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
|
||||
force: true,
|
||||
reason: "mcp:archive-worktree",
|
||||
});
|
||||
expect(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree");
|
||||
expect(markWorkspaceArchiving).toHaveBeenCalledWith(
|
||||
["ws-archive-tool-worktree"],
|
||||
@@ -2611,7 +2762,22 @@ describe("create_agent MCP tool", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("archives every workspace on a directory and removes the directory", async () => {
|
||||
it("rejects archiving a missing workspace", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
listActiveWorkspaces: async () => [],
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(
|
||||
registeredTool(server, "archive_workspace").handler({ workspaceId: "missing-workspace" }),
|
||||
).rejects.toThrow("Workspace not found: missing-workspace");
|
||||
});
|
||||
|
||||
it("keeps an owned worktree while another workspace still references it", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const tempDir = realpathSync.native(
|
||||
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-multi-")),
|
||||
@@ -2670,13 +2836,15 @@ describe("create_agent MCP tool", () => {
|
||||
github: createGitHubServiceStub(),
|
||||
logger,
|
||||
});
|
||||
const createTool = registeredTool(server, "create_worktree");
|
||||
const archiveTool = registeredTool(server, "archive_worktree");
|
||||
const createTool = registeredTool(server, "create_workspace");
|
||||
const archiveTool = registeredTool(server, "archive_workspace");
|
||||
const created = await createTool.handler({
|
||||
cwd: repoDir,
|
||||
target: { kind: "branch-off", worktreeSlug: "archive-multi-worktree", baseBranch: "main" },
|
||||
isolation: "worktree",
|
||||
path: repoDir,
|
||||
worktreeSlug: "archive-multi-worktree",
|
||||
baseBranch: "main",
|
||||
});
|
||||
const worktreePath = z.string().parse(created.structuredContent.worktreePath);
|
||||
const worktreePath = z.string().parse(created.structuredContent.cwd);
|
||||
|
||||
// Populate the active workspaces with the real created path so archiveByScope
|
||||
// matches it against the worktree directory.
|
||||
@@ -2686,19 +2854,18 @@ describe("create_agent MCP tool", () => {
|
||||
];
|
||||
|
||||
await archiveTool.handler({
|
||||
cwd: repoDir,
|
||||
worktreePath,
|
||||
workspaceId: "ws-mcp-A",
|
||||
});
|
||||
|
||||
expect(archivedWorkspaceIds).toContain("ws-mcp-A");
|
||||
expect(archivedWorkspaceIds).toContain("ws-mcp-B");
|
||||
await expect(access(worktreePath)).rejects.toThrow();
|
||||
expect(archivedWorkspaceIds).not.toContain("ws-mcp-B");
|
||||
await expect(access(worktreePath)).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await removeTempDir(tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("archives a worktree by slug", async () => {
|
||||
it("does not expose worktree path or slug operations", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const tempDir = realpathSync.native(
|
||||
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-slug-")),
|
||||
@@ -2746,71 +2913,41 @@ describe("create_agent MCP tool", () => {
|
||||
github: createGitHubServiceStub(),
|
||||
logger,
|
||||
});
|
||||
const createTool = registeredTool(server, "create_worktree");
|
||||
const archiveTool = registeredTool(server, "archive_worktree");
|
||||
const created = await createTool.handler({
|
||||
cwd: repoDir,
|
||||
target: { kind: "branch-off", worktreeSlug: "archive-slug-worktree", baseBranch: "main" },
|
||||
});
|
||||
|
||||
const response = await archiveTool.handler({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "archive-slug-worktree",
|
||||
});
|
||||
|
||||
expect(response.structuredContent).toEqual({ success: true });
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
|
||||
force: true,
|
||||
reason: "archive-worktree",
|
||||
});
|
||||
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir);
|
||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
|
||||
force: true,
|
||||
reason: "mcp:archive-worktree",
|
||||
});
|
||||
await expect(
|
||||
access(z.string().parse(created.structuredContent.worktreePath)),
|
||||
).rejects.toThrow();
|
||||
expect(lookupTool(server, "create_worktree")).toBeUndefined();
|
||||
expect(lookupTool(server, "archive_worktree")).toBeUndefined();
|
||||
} finally {
|
||||
await removeTempDir(tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("routes list_worktrees through WorkspaceGitService", async () => {
|
||||
it("lists active workspace descriptors", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(async () => null),
|
||||
listWorktrees: vi.fn(async () => [
|
||||
{
|
||||
path: "/tmp/paseo/worktrees/repo/feature",
|
||||
branchName: "feature",
|
||||
createdAt: "2026-04-12T00:00:00.000Z",
|
||||
},
|
||||
]),
|
||||
};
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-feature",
|
||||
projectId: "project-1",
|
||||
cwd: "/tmp/paseo/worktrees/repo/feature",
|
||||
kind: "worktree",
|
||||
displayName: "feature",
|
||||
createdAt: "2026-07-17T00:00:00.000Z",
|
||||
updatedAt: "2026-07-17T00:00:00.000Z",
|
||||
});
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees"
|
||||
>,
|
||||
workspaceRegistry: {
|
||||
get: vi.fn(async () => workspace),
|
||||
list: vi.fn(async () => [workspace]),
|
||||
upsert: vi.fn(async () => undefined),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_worktrees");
|
||||
const tool = registeredTool(server, "list_workspaces");
|
||||
|
||||
const response = await tool.handler({ cwd: REPO_CWD });
|
||||
const response = await tool.handler({});
|
||||
|
||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(REPO_CWD, {
|
||||
reason: "mcp:list-worktrees",
|
||||
});
|
||||
expect(response.structuredContent.worktrees).toEqual([
|
||||
{
|
||||
path: "/tmp/paseo/worktrees/repo/feature",
|
||||
branchName: "feature",
|
||||
createdAt: "2026-04-12T00:00:00.000Z",
|
||||
},
|
||||
expect(response.structuredContent.workspaces).toEqual([
|
||||
expect.objectContaining({ workspaceId: "ws-feature", isolation: "worktree" }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -3835,11 +3972,11 @@ describe("create_schedule MCP tool", () => {
|
||||
cron: "*/5 * * * *",
|
||||
name: "Default schedule",
|
||||
}),
|
||||
).rejects.toThrow("provider is required when target is new-agent");
|
||||
).rejects.toThrow("provider");
|
||||
expect(createOrReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps create_schedule provider overrides compatible with provider and provider/model forms", async () => {
|
||||
it("keeps provider forms compatible without materializing default schedule isolation", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
||||
createStoredSchedule(input),
|
||||
@@ -3863,6 +4000,12 @@ describe("create_schedule MCP tool", () => {
|
||||
cron: "*/10 * * * *",
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
await tool.handler({
|
||||
prompt: "say hello in a worktree",
|
||||
cron: "*/15 * * * *",
|
||||
provider: "codex",
|
||||
isolation: "worktree",
|
||||
});
|
||||
|
||||
expect(createOrReplace).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@@ -3889,9 +4032,22 @@ describe("create_schedule MCP tool", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(createOrReplace).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: process.cwd(),
|
||||
isolation: "worktree",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns create_schedule structured content with inherited feature values", async () => {
|
||||
it("inherits the caller provider, model, and features when provider is omitted", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
@@ -3922,12 +4078,17 @@ describe("create_schedule MCP tool", () => {
|
||||
const response = await tool.handler({
|
||||
prompt: "say hello",
|
||||
cron: "*/5 * * * *",
|
||||
provider: "opencode/openai/gpt-5.5",
|
||||
});
|
||||
|
||||
expect(response.structuredContent.target).toMatchObject({
|
||||
expect(response.structuredContent.target).toEqual({
|
||||
type: "new-agent",
|
||||
config: { featureValues: { auto_accept: true } },
|
||||
config: {
|
||||
provider: "opencode",
|
||||
cwd: REPO_CWD,
|
||||
modeId: "build",
|
||||
model: "openai/gpt-5.5",
|
||||
featureValues: { auto_accept: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4103,6 +4264,60 @@ describe("create_heartbeat MCP tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("heartbeat ownership MCP tools", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it("deletes the caller's heartbeat", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const heartbeat = createStoredSchedule({
|
||||
prompt: "check status",
|
||||
cadence: { type: "cron", expression: "*/15 * * * *" },
|
||||
target: { type: "agent", agentId: "parent-agent" },
|
||||
});
|
||||
const inspect = vi.fn(async () => heartbeat);
|
||||
const deleteSchedule = vi.fn(async () => undefined);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: {
|
||||
inspect,
|
||||
delete: deleteSchedule,
|
||||
} as unknown as ScheduleService,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
|
||||
await registeredTool(server, "delete_heartbeat").handler({ id: heartbeat.id });
|
||||
|
||||
expect(deleteSchedule).toHaveBeenCalledWith(heartbeat.id);
|
||||
});
|
||||
|
||||
it("rejects another agent's heartbeat", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const foreignHeartbeat = createStoredSchedule({
|
||||
prompt: "foreign",
|
||||
cadence: { type: "cron", expression: "0 * * * *" },
|
||||
target: { type: "agent", agentId: "other-agent" },
|
||||
});
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: {
|
||||
inspect: vi.fn(async () => foreignHeartbeat),
|
||||
delete: vi.fn(),
|
||||
} as unknown as ScheduleService,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(
|
||||
registeredTool(server, "delete_heartbeat").handler({ id: foreignHeartbeat.id }),
|
||||
).rejects.toThrow("does not belong to caller");
|
||||
});
|
||||
});
|
||||
|
||||
describe("update_schedule MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
@@ -4125,6 +4340,16 @@ describe("update_schedule MCP tool", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleServiceWithUpdate(
|
||||
update: (input: UpdateScheduleInput) => Promise<StoredSchedule>,
|
||||
stored = makeStoredSchedule(),
|
||||
): ScheduleService {
|
||||
return {
|
||||
update,
|
||||
inspect: vi.fn(async () => stored),
|
||||
} as unknown as ScheduleService;
|
||||
}
|
||||
|
||||
it("calls scheduleService.update with correct input", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const stored = makeStoredSchedule();
|
||||
@@ -4138,7 +4363,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4164,7 +4389,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4176,7 +4401,7 @@ describe("update_schedule MCP tool", () => {
|
||||
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
id: "schedule-1",
|
||||
cadence: { type: "every", everyMs: 600000 },
|
||||
cadence: { type: "cron", expression: "*/10 * * * *" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4188,7 +4413,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4217,7 +4442,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4230,7 +4455,7 @@ describe("update_schedule MCP tool", () => {
|
||||
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
id: "schedule-1",
|
||||
cadence: { type: "every", everyMs: 600000 },
|
||||
cadence: { type: "cron", expression: "*/10 * * * *" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4238,7 +4463,7 @@ describe("update_schedule MCP tool", () => {
|
||||
{
|
||||
label: "whitespace cron field",
|
||||
input: { id: "schedule-1", every: "10m", cron: " " },
|
||||
cadence: { type: "every", everyMs: 600000 },
|
||||
cadence: { type: "cron", expression: "*/10 * * * *" },
|
||||
},
|
||||
{
|
||||
label: "blank every field for cron cadence",
|
||||
@@ -4258,7 +4483,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4278,7 +4503,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4300,7 +4525,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4323,7 +4548,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4347,7 +4572,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4381,7 +4606,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4412,7 +4637,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4455,11 +4680,15 @@ describe("schedule_logs MCP tool", () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const runs = [makeRun({ id: "run-1" }), makeRun({ id: "run-2", status: "failed" })];
|
||||
const logs = vi.fn(async (_id: string) => runs);
|
||||
const inspect = vi.fn(async () => ({
|
||||
id: "schedule-1",
|
||||
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp" } },
|
||||
}));
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { logs } as unknown as ScheduleService,
|
||||
scheduleService: { logs, inspect } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "schedule_logs");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`]);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
25
packages/server/src/server/workspace-source.ts
Normal file
25
packages/server/src/server/workspace-source.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import type { ProjectRegistry } from "./workspace-registry.js";
|
||||
|
||||
export interface WorktreeWorkspaceSource {
|
||||
cwd?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export async function resolveWorktreeSourceCwd(
|
||||
source: WorktreeWorkspaceSource,
|
||||
projectRegistry: Pick<ProjectRegistry, "get">,
|
||||
): Promise<string> {
|
||||
if (source.cwd) {
|
||||
return expandTilde(source.cwd);
|
||||
}
|
||||
if (!source.projectId) {
|
||||
throw new Error("cwd or projectId is required for a worktree-backed workspace");
|
||||
}
|
||||
|
||||
const project = await projectRegistry.get(source.projectId);
|
||||
if (!project || project.archivedAt) {
|
||||
throw new Error(`Project not found: ${source.projectId}`);
|
||||
}
|
||||
return project.rootPath;
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user