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
|
## 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:
|
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.
|
||||||
|
|
||||||
- `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`.
|
|
||||||
|
|
||||||
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
- **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.
|
`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.
|
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`):
|
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:
|
Each agent is a single JSON file. Fields relevant to this doc:
|
||||||
|
|
||||||
| Field | Type | Meaning |
|
| Field | Type | Meaning |
|
||||||
| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------- |
|
| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
|
||||||
| `id` | `string` | Stable identifier |
|
| `id` | `string` | Stable identifier |
|
||||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
| `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"` |
|
| `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` |
|
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||||
|
|
||||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||||
|
|||||||
@@ -64,20 +64,20 @@ not retain non-Git directories.
|
|||||||
|
|
||||||
**Key modules:**
|
**Key modules:**
|
||||||
|
|
||||||
| Module | Responsibility |
|
| Module | Responsibility |
|
||||||
| ------------------------------- | ---------------------------------------------------------------------------- |
|
| ------------------------------- | ----------------------------------------------------------------------------- |
|
||||||
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
| `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/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
|
||||||
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
| `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-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/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/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/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/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
||||||
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||||
| `server/schedule/` | Cron-based scheduled agents |
|
| `server/schedule/` | Cron-based scheduled agents |
|
||||||
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
|
| `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 |
|
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
|
||||||
|
|
||||||
### `packages/protocol` — Wire schemas and shared protocol types
|
### `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 terminal ls/create/capture/send-keys/kill`
|
||||||
- `paseo loop run/ls/inspect/logs/stop`
|
- `paseo loop run/ls/inspect/logs/stop`
|
||||||
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
- `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 permit allow/deny/ls`
|
||||||
- `paseo provider ls/models`
|
- `paseo provider ls/models`
|
||||||
- `paseo worktree create/ls/archive`
|
- hidden legacy `paseo worktree create/ls/archive` compatibility alias
|
||||||
- `paseo speech …`
|
- `paseo speech …`
|
||||||
|
|
||||||
Communicates with the daemon via the same WebSocket protocol as the app.
|
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 |
|
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||||
| `title` | `string?` | User-visible title |
|
| `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"` |
|
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||||
| `lastModeId` | `string?` | Last active mode ID |
|
| `lastModeId` | `string?` | Last active mode ID |
|
||||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
| `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`)
|
### Nested: ScheduleCadence (discriminated union on `type`)
|
||||||
|
|
||||||
- `{ type: "every", everyMs: number }` — interval in milliseconds
|
- `{ type: "cron", expression: string, timezone?: string }` — canonical cadence for new writes; absent `timezone` means UTC
|
||||||
- `{ 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: "every", everyMs: number }` — legacy rolling interval, still readable and executable during the compatibility window
|
||||||
|
|
||||||
### Nested: ScheduleTarget (discriminated union on `type`)
|
### 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.
|
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
|
```bash
|
||||||
npm run cli -- ls -a -g # List all agents globally
|
npm run cli -- ls -a -g # List all agents globally
|
||||||
npm run cli -- ls -a -g --json # Same, as JSON
|
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.
|
- **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.
|
- **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).
|
- **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`).
|
- **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".
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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).
|
- **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`).
|
- **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`).
|
- **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).
|
- **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`).
|
- **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`).
|
- **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.
|
- **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
|
- 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
|
- 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
|
- 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
|
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||||
- Frequent releases (multiple per week)
|
- Frequent releases (multiple per week)
|
||||||
- Community contributions across packaging, providers, and bug fixes
|
- 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 { Brain, Folder, GitBranch } from "lucide-react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
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 { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
import { ComboboxItem } from "@/components/ui/combobox";
|
import { ComboboxItem } from "@/components/ui/combobox";
|
||||||
@@ -71,6 +71,15 @@ function parseMaxRuns(raw: string): number | null {
|
|||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 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: {
|
function resolveCreateServerId(input: {
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
serverId: string | null | undefined;
|
serverId: string | null | undefined;
|
||||||
@@ -306,18 +315,15 @@ function OpenScheduleFormSheet({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const submitAgentTarget = useCallback(async (): Promise<boolean> => {
|
const submitAgentTarget = useCallback(async (): Promise<boolean> => {
|
||||||
if (!schedule) {
|
if (!schedule || !state.submitCadence) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await updateSchedule({
|
await updateSchedule({
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
name: state.name.trim() || null,
|
|
||||||
prompt: state.prompt.trim(),
|
|
||||||
cadence: state.submitCadence,
|
cadence: state.submitCadence,
|
||||||
maxRuns: parseMaxRuns(state.maxRuns),
|
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}, [schedule, state.maxRuns, state.name, state.prompt, state.submitCadence, updateSchedule]);
|
}, [schedule, state.submitCadence, updateSchedule]);
|
||||||
|
|
||||||
const submitNewAgent = useCallback(async (): Promise<boolean> => {
|
const submitNewAgent = useCallback(async (): Promise<boolean> => {
|
||||||
const provider = state.selectedProvider;
|
const provider = state.selectedProvider;
|
||||||
@@ -333,7 +339,7 @@ function OpenScheduleFormSheet({
|
|||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
name: state.name.trim() || null,
|
name: state.name.trim() || null,
|
||||||
prompt: state.prompt.trim(),
|
prompt: state.prompt.trim(),
|
||||||
cadence: state.submitCadence,
|
...(state.submitCadence ? { cadence: state.submitCadence } : {}),
|
||||||
newAgentConfig: {
|
newAgentConfig: {
|
||||||
provider,
|
provider,
|
||||||
model: state.selectedModel || null,
|
model: state.selectedModel || null,
|
||||||
@@ -353,7 +359,7 @@ function OpenScheduleFormSheet({
|
|||||||
await createSchedule({
|
await createSchedule({
|
||||||
prompt: state.prompt.trim(),
|
prompt: state.prompt.trim(),
|
||||||
name: state.name.trim() || undefined,
|
name: state.name.trim() || undefined,
|
||||||
cadence: state.submitCadence,
|
cadence: requireCronCadence(state.submitCadence),
|
||||||
target: {
|
target: {
|
||||||
type: "new-agent",
|
type: "new-agent",
|
||||||
config: {
|
config: {
|
||||||
@@ -394,10 +400,12 @@ function OpenScheduleFormSheet({
|
|||||||
void handleSubmit();
|
void handleSubmit();
|
||||||
}, [handleSubmit]);
|
}, [handleSubmit]);
|
||||||
|
|
||||||
const header = useMemo<SheetHeader>(
|
const header = useMemo<SheetHeader>(() => {
|
||||||
() => ({ title: mode === "edit" ? "Edit schedule" : "New schedule" }),
|
if (mode !== "edit") {
|
||||||
[mode],
|
return { title: "New schedule" };
|
||||||
);
|
}
|
||||||
|
return { title: schedule?.target.type === "agent" ? "Edit heartbeat" : "Edit schedule" };
|
||||||
|
}, [mode, schedule?.target.type]);
|
||||||
|
|
||||||
const footer = useMemo(
|
const footer = useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -466,6 +474,21 @@ function ScheduleFormFields({
|
|||||||
cadenceError,
|
cadenceError,
|
||||||
mutationServerId,
|
mutationServerId,
|
||||||
}: ScheduleFormFieldsProps): ReactElement {
|
}: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Field label="Name">
|
<Field label="Name">
|
||||||
@@ -502,7 +525,7 @@ function ScheduleFormFields({
|
|||||||
model={model}
|
model={model}
|
||||||
state={state}
|
state={state}
|
||||||
providerSnapshot={providerSnapshot}
|
providerSnapshot={providerSnapshot}
|
||||||
agentTargetLabel={agentTargetLabel}
|
agentTargetLabel={null}
|
||||||
controlSize={controlSize}
|
controlSize={controlSize}
|
||||||
mutationServerId={mutationServerId}
|
mutationServerId={mutationServerId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
|
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 { formatTimeAgo } from "@/utils/time";
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||||
|
|
||||||
@@ -153,9 +158,10 @@ export function ScheduleRow({
|
|||||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||||
|
|
||||||
const title = resolveScheduleTitle(schedule);
|
const title = resolveScheduleTitle(schedule);
|
||||||
|
const productName = scheduleProductName(schedule);
|
||||||
const badge = stateBadge(state);
|
const badge = stateBadge(state);
|
||||||
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
|
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(
|
const rowStyle = useCallback(
|
||||||
({ pressed }: PressableStateCallbackType) => [
|
({ pressed }: PressableStateCallbackType) => [
|
||||||
@@ -178,7 +184,7 @@ export function ScheduleRow({
|
|||||||
style={rowStyle}
|
style={rowStyle}
|
||||||
onPress={onEdit}
|
onPress={onEdit}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`Edit schedule ${title}`}
|
accessibilityLabel={`Edit ${productName.toLowerCase()} ${title}`}
|
||||||
testID={`schedule-row-${schedule.id}`}
|
testID={`schedule-row-${schedule.id}`}
|
||||||
>
|
>
|
||||||
<View style={styles.main}>
|
<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 runLeading = <ThemedRotateCw size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||||
const deleteLeading = <ThemedTrash2 size={MENU_ICON_SIZE} uniProps={destructiveColorMapping} />;
|
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 {
|
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElement {
|
||||||
return (
|
return (
|
||||||
<ThemedKebab
|
<ThemedKebab
|
||||||
@@ -246,13 +312,15 @@ function ScheduleKebabMenu({
|
|||||||
> & {
|
> & {
|
||||||
canRun: boolean;
|
canRun: boolean;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
|
const productName = scheduleProductName(schedule);
|
||||||
|
const productNameLower = productName.toLowerCase();
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={kebabTriggerStyle}
|
style={kebabTriggerStyle}
|
||||||
accessibilityRole={isNative ? "button" : undefined}
|
accessibilityRole={isNative ? "button" : undefined}
|
||||||
accessibilityLabel="Schedule actions"
|
accessibilityLabel={`${productName} actions`}
|
||||||
testID={`schedule-kebab-${schedule.id}`}
|
testID={`schedule-kebab-${schedule.id}`}
|
||||||
>
|
>
|
||||||
{renderKebabTriggerIcon}
|
{renderKebabTriggerIcon}
|
||||||
@@ -263,41 +331,16 @@ function ScheduleKebabMenu({
|
|||||||
onSelect={onEdit}
|
onSelect={onEdit}
|
||||||
testID={`schedule-menu-edit-${schedule.id}`}
|
testID={`schedule-menu-edit-${schedule.id}`}
|
||||||
>
|
>
|
||||||
Edit schedule
|
Edit {productNameLower}
|
||||||
</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
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<ScheduleExecutionMenuItems
|
||||||
|
schedule={schedule}
|
||||||
|
canRun={canRun}
|
||||||
|
pending={pending}
|
||||||
|
onPause={onPause}
|
||||||
|
onResume={onResume}
|
||||||
|
onRunNow={onRunNow}
|
||||||
|
/>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
leading={deleteLeading}
|
leading={deleteLeading}
|
||||||
@@ -307,7 +350,7 @@ function ScheduleKebabMenu({
|
|||||||
onSelect={onDelete}
|
onSelect={onDelete}
|
||||||
testID={`schedule-menu-delete-${schedule.id}`}
|
testID={`schedule-menu-delete-${schedule.id}`}
|
||||||
>
|
>
|
||||||
Delete schedule
|
Delete {productNameLower}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { AggregatedSchedule } from "@/hooks/use-schedules";
|
|||||||
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
|
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
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. */
|
/** A schedule plus the client-derived fields the row renders. */
|
||||||
export interface ScheduleRowView {
|
export interface ScheduleRowView {
|
||||||
@@ -113,8 +113,9 @@ function SchedulesTableRow({
|
|||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
const handleDelete = useCallback(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
const productName = scheduleProductName(schedule);
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
title: "Delete schedule",
|
title: `Delete ${productName.toLowerCase()}`,
|
||||||
message: `Delete "${resolveScheduleTitle(schedule)}"? This cannot be undone.`,
|
message: `Delete "${resolveScheduleTitle(schedule)}"? This cannot be undone.`,
|
||||||
confirmLabel: "Delete",
|
confirmLabel: "Delete",
|
||||||
destructive: true,
|
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[] } {
|
function providerSnapshot(models: AgentModelDefinition[]): { entries: ProviderSnapshotEntry[] } {
|
||||||
return {
|
return {
|
||||||
entries: [
|
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({
|
const form = open({
|
||||||
mode: "edit",
|
mode: "edit",
|
||||||
schedule: scheduleOnHost({
|
schedule: scheduleOnHost({
|
||||||
@@ -412,9 +432,12 @@ describe("schedule form model", () => {
|
|||||||
expression: "* * * * *",
|
expression: "* * * * *",
|
||||||
timezone: "Europe/Madrid",
|
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 originalCadence = { type: "every" as const, everyMs: 90 * 60_000 };
|
||||||
const form = open({
|
const form = open({
|
||||||
mode: "edit",
|
mode: "edit",
|
||||||
@@ -441,7 +464,7 @@ describe("schedule form model", () => {
|
|||||||
|
|
||||||
form.setName("Renamed without touching cadence");
|
form.setName("Renamed without touching cadence");
|
||||||
|
|
||||||
expect(form.getState().submitCadence).toEqual(originalCadence);
|
expect(form.getState().submitCadence).toBeUndefined();
|
||||||
|
|
||||||
form.setCadence({
|
form.setCadence({
|
||||||
type: "cron",
|
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", () => {
|
it("clears provider selection while resolving a different project", () => {
|
||||||
const form = open({
|
const form = open({
|
||||||
mode: "create",
|
mode: "create",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export interface ScheduleFormProjectOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleFormTargetKind = "agent" | "new-agent";
|
export type ScheduleFormTargetKind = "agent" | "new-agent";
|
||||||
|
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
|
||||||
type ProviderResolutionStatus = "idle" | "pending" | "complete";
|
type ProviderResolutionStatus = "idle" | "pending" | "complete";
|
||||||
|
|
||||||
export interface ScheduleFormState {
|
export interface ScheduleFormState {
|
||||||
@@ -86,7 +87,7 @@ export interface ScheduleFormState {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
maxRuns: string;
|
maxRuns: string;
|
||||||
cadence: ScheduleCadence;
|
cadence: ScheduleCadence;
|
||||||
submitCadence: ScheduleCadence;
|
submitCadence: CronCadence | undefined;
|
||||||
hosts: ScheduleFormHost[];
|
hosts: ScheduleFormHost[];
|
||||||
projectOptions: ScheduleFormProjectOption[];
|
projectOptions: ScheduleFormProjectOption[];
|
||||||
selectedServerId: string | null;
|
selectedServerId: string | null;
|
||||||
@@ -436,13 +437,10 @@ function formatInitialMaxRuns(schedule: ScheduleFormSnapshot["schedule"]): strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveInitialSubmitCadence(
|
function resolveInitialSubmitCadence(
|
||||||
snapshot: ScheduleFormSnapshot,
|
schedule: ScheduleFormSnapshot["schedule"],
|
||||||
initialCadence: ScheduleCadence,
|
initialCadence: CronCadence,
|
||||||
): ScheduleCadence {
|
): CronCadence | undefined {
|
||||||
if (snapshot.mode === "edit" && snapshot.schedule) {
|
return schedule ? undefined : initialCadence;
|
||||||
return snapshot.schedule.cadence;
|
|
||||||
}
|
|
||||||
return initialCadence;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveInitialIsolation(input: {
|
function resolveInitialIsolation(input: {
|
||||||
@@ -547,12 +545,12 @@ function resolveDisclosure(state: ScheduleFormState): ScheduleDisclosureState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveCanSubmit(state: ScheduleFormState): boolean {
|
function resolveCanSubmit(state: ScheduleFormState): boolean {
|
||||||
|
if (state.targetKind === "agent") {
|
||||||
|
return state.submitCadence !== undefined;
|
||||||
|
}
|
||||||
if (state.prompt.trim().length === 0) {
|
if (state.prompt.trim().length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (state.targetKind === "agent") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const hasWorkingDir = state.workingDir.trim().length > 0;
|
const hasWorkingDir = state.workingDir.trim().length > 0;
|
||||||
const hasMatchedProject = state.selectedProjectOptionId.trim().length > 0;
|
const hasMatchedProject = state.selectedProjectOptionId.trim().length > 0;
|
||||||
if (state.mode === "create" && !hasMatchedProject) {
|
if (state.mode === "create" && !hasMatchedProject) {
|
||||||
@@ -657,7 +655,7 @@ function buildInitialState(snapshot: ScheduleFormSnapshot): ScheduleFormState {
|
|||||||
prompt: snapshot.schedule?.prompt ?? "",
|
prompt: snapshot.schedule?.prompt ?? "",
|
||||||
maxRuns: formatInitialMaxRuns(snapshot.schedule),
|
maxRuns: formatInitialMaxRuns(snapshot.schedule),
|
||||||
cadence: initialCadence,
|
cadence: initialCadence,
|
||||||
submitCadence: resolveInitialSubmitCadence(snapshot, initialCadence),
|
submitCadence: resolveInitialSubmitCadence(snapshot.schedule, initialCadence),
|
||||||
hosts: [...snapshot.hosts],
|
hosts: [...snapshot.hosts],
|
||||||
projectOptions: buildProjectOptions(snapshot.defaults.projectTargets, selectedServerId),
|
projectOptions: buildProjectOptions(snapshot.defaults.projectTargets, selectedServerId),
|
||||||
selectedServerId,
|
selectedServerId,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
formatCadence,
|
formatCadence,
|
||||||
formatNextRun,
|
formatNextRun,
|
||||||
isNewAgentSchedule,
|
isNewAgentSchedule,
|
||||||
|
scheduleProductName,
|
||||||
partsToEveryMs,
|
partsToEveryMs,
|
||||||
resolveScheduleTitle,
|
resolveScheduleTitle,
|
||||||
validateCron,
|
validateCron,
|
||||||
@@ -54,6 +55,11 @@ describe("schedule title helpers", () => {
|
|||||||
expect(isNewAgentSchedule(createSchedule({ targetType: "agent" }))).toBe(false);
|
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", () => {
|
it("resolves display titles by name, config title, prompt, then fallback", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveScheduleTitle(createSchedule({ name: "Morning run", title: "Config title" })),
|
resolveScheduleTitle(createSchedule({ name: "Morning run", title: "Config title" })),
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export function isNewAgentSchedule(schedule: ScheduleSummary): boolean {
|
|||||||
return schedule.target.type === "new-agent";
|
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 {
|
export function resolveScheduleTitle(schedule: ScheduleSummary): string {
|
||||||
const name = schedule.name?.trim();
|
const name = schedule.name?.trim();
|
||||||
if (name) {
|
if (name) {
|
||||||
@@ -43,7 +47,7 @@ export function resolveScheduleTitle(schedule: ScheduleSummary): string {
|
|||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.find((line) => line.length > 0);
|
.find((line) => line.length > 0);
|
||||||
return firstPromptLine || "Untitled schedule";
|
return firstPromptLine || `Untitled ${scheduleProductName(schedule).toLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pluralize(value: number, noun: string): string {
|
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 { createSpeechCommand } from "./commands/speech/index.js";
|
||||||
import { createTerminalCommand } from "./commands/terminal/index.js";
|
import { createTerminalCommand } from "./commands/terminal/index.js";
|
||||||
import { createWorktreeCommand } from "./commands/worktree/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 { createHubCommand } from "./commands/hub/index.js";
|
||||||
import { createHooksCommand } from "./commands/hooks.js";
|
import { createHooksCommand } from "./commands/hooks.js";
|
||||||
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
|
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
|
||||||
@@ -174,6 +176,7 @@ export function createCli(): Command {
|
|||||||
|
|
||||||
// Schedule commands
|
// Schedule commands
|
||||||
program.addCommand(createScheduleCommand());
|
program.addCommand(createScheduleCommand());
|
||||||
|
program.addCommand(createHeartbeatCommand());
|
||||||
|
|
||||||
// Permission commands
|
// Permission commands
|
||||||
program.addCommand(createPermitCommand());
|
program.addCommand(createPermitCommand());
|
||||||
@@ -184,8 +187,11 @@ export function createCli(): Command {
|
|||||||
// Speech model commands
|
// Speech model commands
|
||||||
program.addCommand(createSpeechCommand());
|
program.addCommand(createSpeechCommand());
|
||||||
|
|
||||||
// Worktree commands
|
// Workspace commands
|
||||||
program.addCommand(createWorktreeCommand());
|
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;
|
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 { addReloadOptions, runReloadCommand } from "./reload.js";
|
||||||
import { addImportOptions, runImportCommand } from "./import.js";
|
import { addImportOptions, runImportCommand } from "./import.js";
|
||||||
import { runUpdateCommand } from "./update.js";
|
import { runUpdateCommand } from "./update.js";
|
||||||
|
import { runDetachCommand } from "./detach.js";
|
||||||
import { withOutput } from "../../output/index.js";
|
import { withOutput } from "../../output/index.js";
|
||||||
import {
|
import {
|
||||||
addDaemonHostOption,
|
addDaemonHostOption,
|
||||||
@@ -76,6 +77,13 @@ export function createAgentCommand(): Command {
|
|||||||
withOutput(runReloadCommand),
|
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(
|
addJsonAndDaemonHostOptions(
|
||||||
agent
|
agent
|
||||||
.command("update")
|
.command("update")
|
||||||
|
|||||||
@@ -1,5 +1,52 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { runRunCommand, type AgentRunOptions } from "./run";
|
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
|
// validateRunOptions runs before the CLI ever connects to a daemon, so these
|
||||||
// invalid combinations reject without one running.
|
// 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(
|
await expectInvalidOptions(
|
||||||
{ worktree: "feat", workspace: "ws-1" },
|
{ isolation: "worktree", workspace: "ws-1" },
|
||||||
/--worktree and --workspace cannot be combined/,
|
/--isolation and --workspace cannot be combined/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects --worktree combined with an ambient PASEO_WORKSPACE_ID", async () => {
|
it("allows explicit worktree isolation through validation", async () => {
|
||||||
process.env.PASEO_WORKSPACE_ID = "ws-ambient";
|
// Explicit isolation with no --workspace
|
||||||
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
|
|
||||||
// must clear validation. It still fails later (provider resolution), which
|
// must clear validation. It still fails later (provider resolution), which
|
||||||
// is enough to prove the new guard did not reject it.
|
// is enough to prove the new guard did not reject it.
|
||||||
await expect(
|
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" });
|
).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 { resolveProviderAndModel } from "../../utils/provider-model.js";
|
||||||
|
|
||||||
export function addRunOptions(cmd: Command): Command {
|
export function addRunOptions(cmd: Command): Command {
|
||||||
return cmd
|
return (
|
||||||
.description("Create and start an agent with a task")
|
cmd
|
||||||
.argument("<prompt>", "The task/prompt for the agent")
|
.description("Create and start an agent with a task")
|
||||||
.option("-d, --detach", "Run in background (detached)")
|
.argument("<prompt>", "The task/prompt for the agent")
|
||||||
.option("--title <title>", "Assign a title to the agent")
|
.option("-d, --background", "Run in background")
|
||||||
.addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp())
|
// COMPAT(detachRunFlag): --detach used to mean background execution, not
|
||||||
.option(
|
// ownership transfer. Added in v0.2.0; remove after 2027-01-17.
|
||||||
"--provider <provider>",
|
.addOption(new Option("--detach", "Legacy alias for --background").hideHelp())
|
||||||
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
.option("--title <title>", "Assign a title to the agent")
|
||||||
)
|
.addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp())
|
||||||
.option(
|
.option(
|
||||||
"--model <model>",
|
"--provider <provider>",
|
||||||
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
|
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
||||||
)
|
)
|
||||||
.option("--thinking <id>", "Thinking option ID to use for this run")
|
.option(
|
||||||
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
|
"--model <model>",
|
||||||
.option("--worktree <name>", "Create agent in a new git worktree")
|
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
|
||||||
.option("--base <branch>", "Base branch for worktree (default: current branch)")
|
)
|
||||||
.option(
|
.option("--thinking <id>", "Thinking option ID to use for this run")
|
||||||
"--workspace <id>",
|
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
|
||||||
"Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)",
|
.option("--isolation <local|worktree>", "Create a new workspace with this isolation")
|
||||||
)
|
.addOption(new Option("--worktree <name>", "Legacy workspace isolation alias").hideHelp())
|
||||||
.option(
|
.option("--base <branch>", "Base branch for an isolated workspace")
|
||||||
"--image <path>",
|
.option(
|
||||||
"Attach image(s) to the initial prompt (can be used multiple times)",
|
"--workspace <id>",
|
||||||
collectMultiple,
|
"Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)",
|
||||||
[],
|
)
|
||||||
)
|
.option(
|
||||||
.option("--cwd <path>", "Working directory (default: current)")
|
"--image <path>",
|
||||||
.option(
|
"Attach image(s) to the initial prompt (can be used multiple times)",
|
||||||
"--env <key=value>",
|
collectMultiple,
|
||||||
"Set environment variable(s) for the agent process (can be used multiple times)",
|
[],
|
||||||
collectMultiple,
|
)
|
||||||
[],
|
.option("--cwd <path>", "Working directory (default: current)")
|
||||||
)
|
.option(
|
||||||
.option(
|
"--env <key=value>",
|
||||||
"--label <key=value>",
|
"Set environment variable(s) for the agent process (can be used multiple times)",
|
||||||
"Add label(s) to the agent (can be used multiple times)",
|
collectMultiple,
|
||||||
collectMultiple,
|
[],
|
||||||
[],
|
)
|
||||||
)
|
.option(
|
||||||
.option(
|
"--label <key=value>",
|
||||||
"--wait-timeout <duration>",
|
"Add label(s) to the agent (can be used multiple times)",
|
||||||
"Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit",
|
collectMultiple,
|
||||||
)
|
[],
|
||||||
.option(
|
)
|
||||||
"--output-schema <schema>",
|
.option(
|
||||||
"Output JSON matching the provided schema file path or inline JSON schema",
|
"--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 */
|
/** Result type for agent run command */
|
||||||
@@ -91,6 +97,7 @@ export const agentRunSchema: OutputSchema<AgentRunResult> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface AgentRunOptions extends CommandOptions {
|
export interface AgentRunOptions extends CommandOptions {
|
||||||
|
background?: boolean;
|
||||||
detach?: boolean;
|
detach?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -98,6 +105,7 @@ export interface AgentRunOptions extends CommandOptions {
|
|||||||
model?: string;
|
model?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
|
isolation?: string;
|
||||||
worktree?: string;
|
worktree?: string;
|
||||||
base?: string;
|
base?: string;
|
||||||
workspace?: string;
|
workspace?: string;
|
||||||
@@ -270,40 +278,54 @@ function validateRunOptions(prompt: string, options: AgentRunOptions, outputSche
|
|||||||
} satisfies CommandError;
|
} 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 {
|
throw {
|
||||||
code: "INVALID_OPTIONS",
|
code: "INVALID_OPTIONS",
|
||||||
message: "--base can only be used with --worktree",
|
message: `Unsupported workspace isolation: ${options.isolation}`,
|
||||||
details: "Usage: paseo agent run --worktree <name> --base <branch> <prompt>",
|
details: "Use --isolation local or --isolation worktree",
|
||||||
} satisfies CommandError;
|
} 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) {
|
if (options.worktree && options.workspace) {
|
||||||
throw {
|
throw {
|
||||||
code: "INVALID_OPTIONS",
|
code: "INVALID_OPTIONS",
|
||||||
message: "--worktree and --workspace cannot be combined",
|
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;
|
} satisfies CommandError;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.worktree && !options.workspace && process.env.PASEO_WORKSPACE_ID) {
|
if (outputSchema && runsInBackground(options)) {
|
||||||
throw {
|
throw {
|
||||||
code: "INVALID_OPTIONS",
|
code: "INVALID_OPTIONS",
|
||||||
message: "--worktree cannot be combined with an ambient PASEO_WORKSPACE_ID",
|
message: "--output-schema cannot be used with --background",
|
||||||
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",
|
|
||||||
details: "Structured output requires waiting for the agent to finish",
|
details: "Structured output requires waiting for the agent to finish",
|
||||||
} satisfies CommandError;
|
} satisfies CommandError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runsInBackground(options: Pick<AgentRunOptions, "background" | "detach">): boolean {
|
||||||
|
return Boolean(options.background || options.detach);
|
||||||
|
}
|
||||||
|
|
||||||
function parseWaitTimeoutOption(waitTimeout: string | undefined): number {
|
function parseWaitTimeoutOption(waitTimeout: string | undefined): number {
|
||||||
if (!waitTimeout) return 0;
|
if (!waitTimeout) return 0;
|
||||||
try {
|
try {
|
||||||
@@ -409,45 +431,79 @@ async function connectToDaemonOrThrow(
|
|||||||
// runs in. The CLI resolves one before creating any agent, so no run leans on
|
// runs in. The CLI resolves one before creating any agent, so no run leans on
|
||||||
// createAgent's legacy cwd->workspace fallback.
|
// createAgent's legacy cwd->workspace fallback.
|
||||||
interface RunWorkspace {
|
interface RunWorkspace {
|
||||||
id: string;
|
id?: string;
|
||||||
cwd: 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:
|
// Workspace policy for `paseo run`. Precedence:
|
||||||
// 1. --workspace <id> -> run in that existing workspace
|
// 1. --workspace <id> -> run in that existing workspace
|
||||||
// 2. $PASEO_WORKSPACE_ID -> exported by workspace terminals
|
// 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace
|
||||||
// 3. --worktree <name> -> mint a new worktree-backed workspace
|
// 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals
|
||||||
// 4. bare run -> mint a new local-backed workspace for cwd
|
// 4. --isolation <kind> -> mint a new workspace with explicit isolation
|
||||||
// --worktree is rejected alongside both --workspace and an ambient
|
// 5. bare run -> mint a new local-backed workspace for cwd
|
||||||
// $PASEO_WORKSPACE_ID (validateRunOptions), so worktree resolution here never
|
|
||||||
// races an existing-workspace selection.
|
|
||||||
async function resolveRunWorkspace(
|
async function resolveRunWorkspace(
|
||||||
client: ConnectedDaemonClient,
|
client: ConnectedDaemonClient,
|
||||||
options: AgentRunOptions,
|
options: AgentRunOptions,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
): Promise<RunWorkspace> {
|
): Promise<RunWorkspace> {
|
||||||
// An explicit --worktree mints its own workspace; --workspace and an ambient
|
const requestedIsolation = options.isolation ?? (options.worktree ? "worktree" : undefined);
|
||||||
// PASEO_WORKSPACE_ID are both rejected alongside --worktree upstream.
|
const explicit = requestedIsolation ? undefined : options.workspace?.trim();
|
||||||
const explicit = options.worktree
|
|
||||||
? undefined
|
|
||||||
: options.workspace?.trim() || process.env.PASEO_WORKSPACE_ID?.trim();
|
|
||||||
if (explicit) {
|
if (explicit) {
|
||||||
console.error(`Using workspace ${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
|
// TODO: thread the run `prompt` as firstAgentContext so workspace-level
|
||||||
// title/branch generation picks up the task description (U8/U6 deferred).
|
// title/branch generation picks up the task description (U8/U6 deferred).
|
||||||
const result = options.worktree
|
const result =
|
||||||
? await client.createWorkspace({
|
requestedIsolation === "worktree"
|
||||||
source: {
|
? await client.createWorkspace({
|
||||||
kind: "worktree",
|
source: {
|
||||||
cwd,
|
kind: "worktree",
|
||||||
worktreeSlug: options.worktree,
|
cwd,
|
||||||
baseBranch: options.base,
|
worktreeSlug: options.worktree,
|
||||||
},
|
baseBranch: options.base,
|
||||||
})
|
},
|
||||||
: await client.createWorkspace({ source: { kind: "directory", path: cwd } });
|
})
|
||||||
|
: await client.createWorkspace({ source: { kind: "directory", path: cwd } });
|
||||||
|
|
||||||
if (!result.workspace) {
|
if (!result.workspace) {
|
||||||
throw {
|
throw {
|
||||||
@@ -503,6 +559,7 @@ export async function runRunCommand(
|
|||||||
|
|
||||||
const workspace = await resolveRunWorkspace(client, options, cwd);
|
const workspace = await resolveRunWorkspace(client, options, cwd);
|
||||||
const workspaceId = workspace.id;
|
const workspaceId = workspace.id;
|
||||||
|
const callerAgentId = resolveRunCallerAgentId();
|
||||||
const runCwd = workspace.cwd;
|
const runCwd = workspace.cwd;
|
||||||
|
|
||||||
if (outputSchema) {
|
if (outputSchema) {
|
||||||
@@ -514,6 +571,7 @@ export async function runRunCommand(
|
|||||||
provider: resolvedProviderModel.provider,
|
provider: resolvedProviderModel.provider,
|
||||||
cwd: runCwd,
|
cwd: runCwd,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
callerAgentId,
|
||||||
title: resolvedTitle,
|
title: resolvedTitle,
|
||||||
modeId: options.mode,
|
modeId: options.mode,
|
||||||
model: resolvedProviderModel.model,
|
model: resolvedProviderModel.model,
|
||||||
@@ -584,6 +642,7 @@ export async function runRunCommand(
|
|||||||
provider: resolvedProviderModel.provider,
|
provider: resolvedProviderModel.provider,
|
||||||
cwd: runCwd,
|
cwd: runCwd,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
callerAgentId,
|
||||||
title: resolvedTitle,
|
title: resolvedTitle,
|
||||||
modeId: options.mode,
|
modeId: options.mode,
|
||||||
model: resolvedProviderModel.model,
|
model: resolvedProviderModel.model,
|
||||||
@@ -594,8 +653,8 @@ export async function runRunCommand(
|
|||||||
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default run behavior is foreground: wait for completion unless --detach is set.
|
// Default run behavior is foreground: wait for completion unless background execution is set.
|
||||||
if (!options.detach) {
|
if (!runsInBackground(options)) {
|
||||||
const state = await client.waitForFinish(agent.id, waitTimeoutMs);
|
const state = await client.waitForFinish(agent.id, waitTimeoutMs);
|
||||||
await client.close();
|
await client.close();
|
||||||
|
|
||||||
@@ -631,3 +690,9 @@ export async function runRunCommand(
|
|||||||
throw error;
|
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 { withOutput } from "../../output/index.js";
|
||||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||||
import { runCreateCommand } from "./create.js";
|
import { runCreateCommand } from "./create.js";
|
||||||
@@ -19,11 +19,11 @@ export function createScheduleCommand(): Command {
|
|||||||
.command("create")
|
.command("create")
|
||||||
.description("Create a schedule")
|
.description("Create a schedule")
|
||||||
.argument("<prompt>", "Prompt to run on the 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("--cron <expr>", "Cron cadence expression")
|
||||||
.option("--timezone <iana>", "IANA time zone for cron cadence (default: UTC)")
|
.option("--timezone <iana>", "IANA time zone for cron cadence (default: UTC)")
|
||||||
.option("--name <name>", "Optional schedule name")
|
.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(
|
.option(
|
||||||
"--provider <provider>",
|
"--provider <provider>",
|
||||||
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
"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)",
|
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
|
||||||
)
|
)
|
||||||
.option("--cwd <path>", "Working directory (default: current; required with --host)")
|
.option("--cwd <path>", "Working directory (default: current; required with --host)")
|
||||||
.option("--run-now", "Fire one immediate run on creation (only with --cron)")
|
.option("--run-now", "Fire one immediate run on creation")
|
||||||
.option("--no-run-now", "Wait the full interval before the first run (only with --every)")
|
|
||||||
.option("--max-runs <n>", "Maximum number of runs")
|
.option("--max-runs <n>", "Maximum number of runs")
|
||||||
.option("--expires-in <duration>", "Time to live for the schedule"),
|
.option("--expires-in <duration>", "Time to live for the schedule"),
|
||||||
).action(withOutput(runCreateCommand));
|
).action(withOutput(runCreateCommand));
|
||||||
@@ -81,7 +80,7 @@ export function createScheduleCommand(): Command {
|
|||||||
.command("update")
|
.command("update")
|
||||||
.description("Update an existing schedule in place")
|
.description("Update an existing schedule in place")
|
||||||
.argument("<id>", "Schedule ID")
|
.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("--cron <expr>", "Switch to cron cadence expression")
|
||||||
.option("--timezone <iana>", "IANA time zone for cron cadence (requires --cron)")
|
.option("--timezone <iana>", "IANA time zone for cron cadence (requires --cron)")
|
||||||
.option("--name <name>", "Rename the schedule (empty string clears the name)")
|
.option("--name <name>", "Rename the schedule (empty string clears the name)")
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export async function runInspectCommand(
|
|||||||
if (payload.error || !payload.schedule) {
|
if (payload.error || !payload.schedule) {
|
||||||
throw new Error(payload.error ?? `Schedule not found: ${id}`);
|
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);
|
const rows = createScheduleInspectRows(payload.schedule);
|
||||||
return {
|
return {
|
||||||
type: "list",
|
type: "list",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ListResult } from "../../output/index.js";
|
|||||||
import { scheduleLogSchema, toScheduleLogRow, type ScheduleLogRow } from "./schema.js";
|
import { scheduleLogSchema, toScheduleLogRow, type ScheduleLogRow } from "./schema.js";
|
||||||
import {
|
import {
|
||||||
connectScheduleClient,
|
connectScheduleClient,
|
||||||
|
requireNewAgentSchedule,
|
||||||
toScheduleCommandError,
|
toScheduleCommandError,
|
||||||
type ScheduleCommandOptions,
|
type ScheduleCommandOptions,
|
||||||
} from "./shared.js";
|
} from "./shared.js";
|
||||||
@@ -14,6 +15,7 @@ export async function runLogsCommand(
|
|||||||
): Promise<ListResult<ScheduleLogRow>> {
|
): Promise<ListResult<ScheduleLogRow>> {
|
||||||
const { client } = await connectScheduleClient(options.host);
|
const { client } = await connectScheduleClient(options.host);
|
||||||
try {
|
try {
|
||||||
|
await requireNewAgentSchedule(client, id);
|
||||||
const payload = await client.scheduleLogs({ id });
|
const payload = await client.scheduleLogs({ id });
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ export async function runLsCommand(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: "list",
|
type: "list",
|
||||||
data: payload.schedules.map(toScheduleRow),
|
data: payload.schedules
|
||||||
|
.filter((schedule) => schedule.target.type === "new-agent")
|
||||||
|
.map(toScheduleRow),
|
||||||
schema: scheduleSchema,
|
schema: scheduleSchema,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { SingleResult } from "../../output/index.js";
|
|||||||
import { scheduleSchema } from "./schema.js";
|
import { scheduleSchema } from "./schema.js";
|
||||||
import {
|
import {
|
||||||
connectScheduleClient,
|
connectScheduleClient,
|
||||||
|
requireNewAgentSchedule,
|
||||||
toScheduleCommandError,
|
toScheduleCommandError,
|
||||||
toScheduleRow,
|
toScheduleRow,
|
||||||
type ScheduleCommandOptions,
|
type ScheduleCommandOptions,
|
||||||
@@ -16,6 +17,7 @@ export async function runPauseCommand(
|
|||||||
): Promise<SingleResult<ScheduleRow>> {
|
): Promise<SingleResult<ScheduleRow>> {
|
||||||
const { client } = await connectScheduleClient(options.host);
|
const { client } = await connectScheduleClient(options.host);
|
||||||
try {
|
try {
|
||||||
|
await requireNewAgentSchedule(client, id);
|
||||||
const payload = await client.schedulePause({ id });
|
const payload = await client.schedulePause({ id });
|
||||||
if (payload.error || !payload.schedule) {
|
if (payload.error || !payload.schedule) {
|
||||||
throw new Error(payload.error ?? `Failed to pause schedule: ${id}`);
|
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 { scheduleSchema } from "./schema.js";
|
||||||
import {
|
import {
|
||||||
connectScheduleClient,
|
connectScheduleClient,
|
||||||
|
requireNewAgentSchedule,
|
||||||
toScheduleCommandError,
|
toScheduleCommandError,
|
||||||
toScheduleRow,
|
toScheduleRow,
|
||||||
type ScheduleCommandOptions,
|
type ScheduleCommandOptions,
|
||||||
@@ -16,6 +17,7 @@ export async function runResumeCommand(
|
|||||||
): Promise<SingleResult<ScheduleRow>> {
|
): Promise<SingleResult<ScheduleRow>> {
|
||||||
const { client } = await connectScheduleClient(options.host);
|
const { client } = await connectScheduleClient(options.host);
|
||||||
try {
|
try {
|
||||||
|
await requireNewAgentSchedule(client, id);
|
||||||
const payload = await client.scheduleResume({ id });
|
const payload = await client.scheduleResume({ id });
|
||||||
if (payload.error || !payload.schedule) {
|
if (payload.error || !payload.schedule) {
|
||||||
throw new Error(payload.error ?? `Failed to resume schedule: ${id}`);
|
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 { scheduleSchema } from "./schema.js";
|
||||||
import {
|
import {
|
||||||
connectScheduleClient,
|
connectScheduleClient,
|
||||||
|
requireNewAgentSchedule,
|
||||||
toScheduleCommandError,
|
toScheduleCommandError,
|
||||||
toScheduleRow,
|
toScheduleRow,
|
||||||
type ScheduleCommandOptions,
|
type ScheduleCommandOptions,
|
||||||
@@ -16,6 +17,7 @@ export async function runRunOnceCommand(
|
|||||||
): Promise<SingleResult<ScheduleRow>> {
|
): Promise<SingleResult<ScheduleRow>> {
|
||||||
const { client } = await connectScheduleClient(options.host);
|
const { client } = await connectScheduleClient(options.host);
|
||||||
try {
|
try {
|
||||||
|
await requireNewAgentSchedule(client, id);
|
||||||
const payload = await client.scheduleRunOnce({ id });
|
const payload = await client.scheduleRunOnce({ id });
|
||||||
if (payload.error || !payload.schedule) {
|
if (payload.error || !payload.schedule) {
|
||||||
throw new Error(payload.error ?? `Failed to run schedule once: ${id}`);
|
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 { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { parseScheduleCreateInput, parseScheduleUpdateInput } from "./shared.js";
|
import {
|
||||||
|
compileEveryPresetToCron,
|
||||||
|
parseScheduleCreateInput,
|
||||||
|
parseScheduleUpdateInput,
|
||||||
|
} from "./shared.js";
|
||||||
|
|
||||||
const baseOptions = {
|
const baseOptions = {
|
||||||
prompt: "do the thing",
|
prompt: "do the thing",
|
||||||
@@ -68,13 +72,9 @@ describe("parseScheduleCreateInput cwd/host validation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("parseScheduleCreateInput first-run timing", () => {
|
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);
|
const input = parseScheduleCreateInput(baseOptions);
|
||||||
expect(input.runOnCreate).toBe(true);
|
expect(input.cadence).toEqual({ type: "cron", expression: "*/5 * * * *" });
|
||||||
});
|
|
||||||
|
|
||||||
test("--every with --no-run-now waits the interval", () => {
|
|
||||||
const input = parseScheduleCreateInput({ ...baseOptions, runNow: false });
|
|
||||||
expect(input.runOnCreate).toBe(false);
|
expect(input.runOnCreate).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,22 +101,9 @@ describe("parseScheduleCreateInput first-run timing", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("--every with --run-now is rejected as redundant", () => {
|
test("--run-now is orthogonal to a compiled preset", () => {
|
||||||
expect(() => parseScheduleCreateInput({ ...baseOptions, runNow: true })).toThrow(
|
const input = parseScheduleCreateInput({ ...baseOptions, runNow: true });
|
||||||
expect.objectContaining({
|
expect(input.runOnCreate).toBe(true);
|
||||||
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("--timezone without --cron is rejected", () => {
|
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({
|
expect(parseScheduleUpdateInput({ id: "abc", every: "5m" })).toEqual({
|
||||||
id: "abc",
|
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" }));
|
).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";
|
} from "./types.js";
|
||||||
import { parseDuration } from "../../utils/duration.js";
|
import { parseDuration } from "../../utils/duration.js";
|
||||||
import { resolveProviderAndModel } from "../../utils/provider-model.js";
|
import { resolveProviderAndModel } from "../../utils/provider-model.js";
|
||||||
|
import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence";
|
||||||
|
|
||||||
export interface ScheduleCommandOptions extends CommandOptions {
|
export interface ScheduleCommandOptions extends CommandOptions {
|
||||||
host?: string;
|
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 {
|
export function formatCadence(cadence: ScheduleCadence): string {
|
||||||
if (cadence.type === "cron") {
|
if (cadence.type === "cron") {
|
||||||
const timezoneSuffix = cadence.timezone ? ` (${cadence.timezone})` : "";
|
const timezoneSuffix = cadence.timezone ? ` (${cadence.timezone})` : "";
|
||||||
@@ -92,12 +103,7 @@ function resolveScheduleTarget(args: {
|
|||||||
createNewAgentTarget: () => ScheduleTarget;
|
createNewAgentTarget: () => ScheduleTarget;
|
||||||
}): ScheduleTarget {
|
}): ScheduleTarget {
|
||||||
const { targetValue, hasExplicitNewAgentOption, createNewAgentTarget } = args;
|
const { targetValue, hasExplicitNewAgentOption, createNewAgentTarget } = args;
|
||||||
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
|
|
||||||
|
|
||||||
if (!targetValue) {
|
if (!targetValue) {
|
||||||
if (currentAgentId && !hasExplicitNewAgentOption) {
|
|
||||||
return { type: "self", agentId: currentAgentId };
|
|
||||||
}
|
|
||||||
return createNewAgentTarget();
|
return createNewAgentTarget();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +120,9 @@ function resolveScheduleTarget(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (targetValue === "self") {
|
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) {
|
if (!currentAgentId) {
|
||||||
throw {
|
throw {
|
||||||
code: "INVALID_TARGET",
|
code: "INVALID_TARGET",
|
||||||
@@ -211,23 +220,9 @@ export function parseScheduleCreateInput(options: {
|
|||||||
|
|
||||||
function resolveRunOnCreate(
|
function resolveRunOnCreate(
|
||||||
runNow: boolean | undefined,
|
runNow: boolean | undefined,
|
||||||
cadenceType: ScheduleCadence["type"],
|
_cadenceType: ScheduleCadence["type"],
|
||||||
): boolean {
|
): boolean {
|
||||||
if (runNow === true && cadenceType === "every") {
|
return runNow ?? false;
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleUpdateOptionsInput {
|
export interface ScheduleUpdateOptionsInput {
|
||||||
@@ -307,7 +302,7 @@ function parseCadenceFromFlags(
|
|||||||
} satisfies CommandError;
|
} satisfies CommandError;
|
||||||
}
|
}
|
||||||
if (every !== undefined) {
|
if (every !== undefined) {
|
||||||
return { type: "every", everyMs: parseDuration(every) };
|
return { type: "cron", expression: compileEveryPresetToCron(every) };
|
||||||
}
|
}
|
||||||
if (cron !== undefined) {
|
if (cron !== undefined) {
|
||||||
return {
|
return {
|
||||||
@@ -319,6 +314,20 @@ function parseCadenceFromFlags(
|
|||||||
return undefined;
|
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 {
|
function parseTimeZoneFlag(timeZone: string | undefined): string | undefined {
|
||||||
if (timeZone === undefined) {
|
if (timeZone === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
connectScheduleClient,
|
connectScheduleClient,
|
||||||
parseScheduleUpdateInput,
|
parseScheduleUpdateInput,
|
||||||
|
requireNewAgentSchedule,
|
||||||
toScheduleCommandError,
|
toScheduleCommandError,
|
||||||
type ScheduleCommandOptions,
|
type ScheduleCommandOptions,
|
||||||
} from "./shared.js";
|
} from "./shared.js";
|
||||||
@@ -51,6 +52,7 @@ export async function runUpdateCommand(
|
|||||||
});
|
});
|
||||||
const { client } = await connectScheduleClient(options.host);
|
const { client } = await connectScheduleClient(options.host);
|
||||||
try {
|
try {
|
||||||
|
await requireNewAgentSchedule(client, id);
|
||||||
const payload = await client.scheduleUpdate(input);
|
const payload = await client.scheduleUpdate(input);
|
||||||
if (payload.error || !payload.schedule) {
|
if (payload.error || !payload.schedule) {
|
||||||
throw new Error(payload.error ?? `Failed to update schedule: ${id}`);
|
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.
|
* 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.
|
* If no unit is specified, assumes seconds.
|
||||||
*/
|
*/
|
||||||
export function parseDuration(input: string): number {
|
export function parseDuration(input: string): number {
|
||||||
@@ -11,14 +11,16 @@ export function parseDuration(input: string): number {
|
|||||||
return parseInt(trimmed, 10) * 1000;
|
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
|
// Parse duration with units
|
||||||
let totalMs = 0;
|
let totalMs = 0;
|
||||||
const regex = /(\d+)([smh])/g;
|
const regex = /(\d+)([smhd])/g;
|
||||||
let match;
|
let match;
|
||||||
let hasMatch = false;
|
|
||||||
|
|
||||||
while ((match = regex.exec(trimmed)) !== null) {
|
while ((match = regex.exec(trimmed)) !== null) {
|
||||||
hasMatch = true;
|
|
||||||
const value = parseInt(match[1], 10);
|
const value = parseInt(match[1], 10);
|
||||||
const unit = match[2];
|
const unit = match[2];
|
||||||
|
|
||||||
@@ -32,12 +34,11 @@ export function parseDuration(input: string): number {
|
|||||||
case "h":
|
case "h":
|
||||||
totalMs += value * 60 * 60 * 1000;
|
totalMs += value * 60 * 60 * 1000;
|
||||||
break;
|
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;
|
return totalMs;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ try {
|
|||||||
const result = await $`npx paseo run --help`.nothrow();
|
const result = await $`npx paseo run --help`.nothrow();
|
||||||
assert.strictEqual(result.exitCode, 0, "run --help should exit 0");
|
assert.strictEqual(result.exitCode, 0, "run --help should exit 0");
|
||||||
assert(result.stdout.includes("-d"), "help should mention -d flag");
|
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("--title"), "help should mention --title option");
|
||||||
assert(result.stdout.includes("--provider"), "help should mention --provider option");
|
assert(result.stdout.includes("--provider"), "help should mention --provider option");
|
||||||
assert(result.stdout.includes("--mode"), "help should mention --mode option");
|
assert(result.stdout.includes("--mode"), "help should mention --mode option");
|
||||||
@@ -174,18 +175,18 @@ try {
|
|||||||
console.log("✓ run --output-schema flag is accepted\n");
|
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 =
|
const result =
|
||||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d --output-schema ${schemaPath} "test prompt"`.nothrow();
|
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 --detach and --output-schema");
|
assert.notStrictEqual(result.exitCode, 0, "should fail with --background and --output-schema");
|
||||||
const output = result.stdout + result.stderr;
|
const output = result.stdout + result.stderr;
|
||||||
assert(
|
assert(
|
||||||
output.includes("--output-schema cannot be used with --detach"),
|
output.includes("--output-schema cannot be used with --background"),
|
||||||
"error should explain detach incompatibility",
|
"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
|
// Test 11: -q (quiet) flag is accepted with run
|
||||||
|
|||||||
@@ -153,13 +153,13 @@ try {
|
|||||||
console.log("✓ --json flag is accepted with worktree ls\n");
|
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();
|
const result = await $`npx paseo --help`.nothrow();
|
||||||
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
|
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
|
||||||
assert(result.stdout.includes("worktree"), "help should mention worktree subcommand");
|
assert(!result.stdout.includes("worktree"), "help should not advertise worktree subcommand");
|
||||||
console.log("✓ paseo --help shows worktree subcommand\n");
|
console.log("✓ paseo --help hides worktree compatibility command\n");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up temp directory
|
// Clean up temp directory
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ try {
|
|||||||
assert.strictEqual(created.exitCode, 0, created.stderr);
|
assert.strictEqual(created.exitCode, 0, created.stderr);
|
||||||
const createdJson = JSON.parse(created.stdout);
|
const createdJson = JSON.parse(created.stdout);
|
||||||
assert.strictEqual(createdJson.name, "review-prs");
|
assert.strictEqual(createdJson.name, "review-prs");
|
||||||
assert.strictEqual(createdJson.cadence, "every:5m");
|
assert.strictEqual(createdJson.cadence, "cron:*/5 * * * *");
|
||||||
assert(
|
assert(
|
||||||
typeof createdJson.target === "string" &&
|
typeof createdJson.target === "string" &&
|
||||||
(createdJson.target.startsWith("agent:") || createdJson.target === "new-agent:claude"),
|
(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("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");
|
console.log("Test 2: loop run/ls/inspect/logs/stop work");
|
||||||
const run = await ctx.paseo(
|
const run = await ctx.paseo(
|
||||||
|
|||||||
@@ -723,7 +723,7 @@ test("sends new-agent run options when creating schedules", async () => {
|
|||||||
const createPromise = client.scheduleCreate({
|
const createPromise = client.scheduleCreate({
|
||||||
requestId: "request-1",
|
requestId: "request-1",
|
||||||
prompt: "Run the task",
|
prompt: "Run the task",
|
||||||
cadence: { type: "every", everyMs: 60_000 },
|
cadence: { type: "cron", expression: "* * * * *" },
|
||||||
target: {
|
target: {
|
||||||
type: "new-agent",
|
type: "new-agent",
|
||||||
config: {
|
config: {
|
||||||
@@ -741,7 +741,7 @@ test("sends new-agent run options when creating schedules", async () => {
|
|||||||
type: "schedule/create",
|
type: "schedule/create",
|
||||||
requestId: "request-1",
|
requestId: "request-1",
|
||||||
prompt: "Run the task",
|
prompt: "Run the task",
|
||||||
cadence: { type: "every", everyMs: 60_000 },
|
cadence: { type: "cron", expression: "* * * * *" },
|
||||||
target: {
|
target: {
|
||||||
type: "new-agent",
|
type: "new-agent",
|
||||||
config: {
|
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 logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|
||||||
@@ -2012,6 +2012,7 @@ test("sends create_agent_request with string workspace ids", async () => {
|
|||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: "/tmp/project/.paseo/worktrees/feature-a",
|
cwd: "/tmp/project/.paseo/worktrees/feature-a",
|
||||||
workspaceId: "ws-feature-a",
|
workspaceId: "ws-feature-a",
|
||||||
|
callerAgentId: "parent-agent",
|
||||||
title: "Compat agent",
|
title: "Compat agent",
|
||||||
modeId: "default",
|
modeId: "default",
|
||||||
});
|
});
|
||||||
@@ -2022,6 +2023,7 @@ test("sends create_agent_request with string workspace ids", async () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: "create_agent_request",
|
type: "create_agent_request",
|
||||||
workspaceId: "ws-feature-a",
|
workspaceId: "ws-feature-a",
|
||||||
|
callerAgentId: "parent-agent",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -338,6 +338,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
env?: CreateAgentRequestMessage["env"];
|
env?: CreateAgentRequestMessage["env"];
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
|
callerAgentId?: string;
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
clientMessageId?: string;
|
clientMessageId?: string;
|
||||||
outputSchema?: Record<string, unknown>;
|
outputSchema?: Record<string, unknown>;
|
||||||
@@ -346,6 +347,8 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
|
|||||||
git?: GitSetupOptions;
|
git?: GitSetupOptions;
|
||||||
worktree?: CreateAgentRequestMessage["worktree"];
|
worktree?: CreateAgentRequestMessage["worktree"];
|
||||||
autoArchive?: CreateAgentRequestMessage["autoArchive"];
|
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;
|
worktreeName?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
@@ -739,16 +742,11 @@ export interface StopLoopOptions {
|
|||||||
export interface CreateScheduleOptions {
|
export interface CreateScheduleOptions {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
cadence:
|
cadence: {
|
||||||
| {
|
type: "cron";
|
||||||
type: "every";
|
expression: string;
|
||||||
everyMs: number;
|
timezone?: string;
|
||||||
}
|
};
|
||||||
| {
|
|
||||||
type: "cron";
|
|
||||||
expression: string;
|
|
||||||
timezone?: string;
|
|
||||||
};
|
|
||||||
target:
|
target:
|
||||||
| {
|
| {
|
||||||
type: "self";
|
type: "self";
|
||||||
@@ -800,16 +798,11 @@ export interface UpdateScheduleOptions {
|
|||||||
id: string;
|
id: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
cadence?:
|
cadence?: {
|
||||||
| {
|
type: "cron";
|
||||||
type: "every";
|
expression: string;
|
||||||
everyMs: number;
|
timezone?: string;
|
||||||
}
|
};
|
||||||
| {
|
|
||||||
type: "cron";
|
|
||||||
expression: string;
|
|
||||||
timezone?: string;
|
|
||||||
};
|
|
||||||
newAgentConfig?: UpdateScheduleNewAgentConfig;
|
newAgentConfig?: UpdateScheduleNewAgentConfig;
|
||||||
maxRuns?: number | null;
|
maxRuns?: number | null;
|
||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
@@ -2290,6 +2283,7 @@ export class DaemonClient {
|
|||||||
config,
|
config,
|
||||||
...(options.env ? { env: options.env } : {}),
|
...(options.env ? { env: options.env } : {}),
|
||||||
...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
|
...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
|
||||||
|
...(options.callerAgentId !== undefined ? { callerAgentId: options.callerAgentId } : {}),
|
||||||
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
|
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
|
||||||
...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
|
...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
|
||||||
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
|
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
|
||||||
|
|||||||
@@ -168,13 +168,13 @@ export interface PaseoAgentCreateOptions extends PaseoAgentConfigOverrides {
|
|||||||
provider?: CreateAgentRequestMessage["config"]["provider"];
|
provider?: CreateAgentRequestMessage["config"]["provider"];
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
|
callerAgentId?: string;
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
clientMessageId?: string;
|
clientMessageId?: string;
|
||||||
outputSchema?: Record<string, unknown>;
|
outputSchema?: Record<string, unknown>;
|
||||||
images?: CreateAgentRequestMessage["images"];
|
images?: CreateAgentRequestMessage["images"];
|
||||||
attachments?: CreateAgentRequestMessage["attachments"];
|
attachments?: CreateAgentRequestMessage["attachments"];
|
||||||
git?: CreateAgentRequestMessage["git"];
|
git?: CreateAgentRequestMessage["git"];
|
||||||
worktreeName?: string;
|
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -981,7 +981,7 @@ const AgentAttachmentsSchema = z.unknown().transform(normalizeAgentAttachments).
|
|||||||
|
|
||||||
export const ChangeRequestCheckoutSourceSchema = z.object({
|
export const ChangeRequestCheckoutSourceSchema = z.object({
|
||||||
kind: z.literal("change_request"),
|
kind: z.literal("change_request"),
|
||||||
forge: z.string().optional().default("github"),
|
forge: z.string().optional(),
|
||||||
number: z.number().int().positive(),
|
number: z.number().int().positive(),
|
||||||
projectPath: z.string().optional(),
|
projectPath: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -1249,6 +1249,9 @@ export const CreateAgentRequestMessageSchema = z.object({
|
|||||||
config: AgentSessionConfigSchema,
|
config: AgentSessionConfigSchema,
|
||||||
env: z.record(z.string(), z.string()).optional(),
|
env: z.record(z.string(), z.string()).optional(),
|
||||||
workspaceId: 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(),
|
worktreeName: z.string().optional(),
|
||||||
initialPrompt: z.string().optional(),
|
initialPrompt: z.string().optional(),
|
||||||
clientMessageId: z.string().optional(),
|
clientMessageId: z.string().optional(),
|
||||||
@@ -2061,9 +2064,11 @@ export const WorkspaceCreateRequestSchema = z.object({
|
|||||||
cwd: z.string().optional(),
|
cwd: z.string().optional(),
|
||||||
projectId: z.string().optional(),
|
projectId: z.string().optional(),
|
||||||
action: z.enum(["branch-off", "checkout"]).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(),
|
refName: z.string().min(1).optional(),
|
||||||
baseBranch: z.string().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(),
|
checkoutSource: ChangeRequestCheckoutSourceSchema.optional(),
|
||||||
// COMPAT(githubPrNumber): added in v0.1.106, remove after 2026-12-28 once
|
// COMPAT(githubPrNumber): added in v0.1.106, remove after 2026-12-28 once
|
||||||
// clients send checkoutSource.
|
// clients send checkoutSource.
|
||||||
|
|||||||
@@ -1031,6 +1031,25 @@ describe("workspace message schemas", () => {
|
|||||||
expect(newWorktree.type).toBe("workspace.create.request");
|
expect(newWorktree.type).toBe("workspace.create.request");
|
||||||
expect(newWorktree.source.kind).toBe("worktree");
|
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.
|
// Directory source must also be accepted.
|
||||||
const newDirectory = WorkspaceCreateRequestSchema.parse({
|
const newDirectory = WorkspaceCreateRequestSchema.parse({
|
||||||
type: "workspace.create.request",
|
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 {
|
interface FinishNotificationScenarioOptions {
|
||||||
childLastAssistantMessage?: string | null;
|
childLastAssistantMessage?: string | null;
|
||||||
|
childParentAgentId?: string | null;
|
||||||
|
requireParentOwnership?: boolean;
|
||||||
parentPromptError?: Error;
|
parentPromptError?: Error;
|
||||||
logger?: Logger;
|
logger?: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FinishNotificationScenario {
|
interface FinishNotificationScenario {
|
||||||
startWatchingChild(): void;
|
startWatchingChild(): void;
|
||||||
|
finishChild(): void;
|
||||||
finishChildAndReadParentPrompt(): Promise<string>;
|
finishChildAndReadParentPrompt(): Promise<string>;
|
||||||
|
wasParentPrompted(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFinishNotificationScenario(
|
function createFinishNotificationScenario(
|
||||||
@@ -52,6 +56,7 @@ function createFinishNotificationScenario(
|
|||||||
): FinishNotificationScenario {
|
): FinishNotificationScenario {
|
||||||
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
|
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
|
||||||
let resolveParentPrompt: ((prompt: string) => void) | null = null;
|
let resolveParentPrompt: ((prompt: string) => void) | null = null;
|
||||||
|
let parentPrompted = false;
|
||||||
|
|
||||||
const childAgent: ManagedAgent = Object.create(null);
|
const childAgent: ManagedAgent = Object.create(null);
|
||||||
Reflect.set(childAgent, "id", "child-agent");
|
Reflect.set(childAgent, "id", "child-agent");
|
||||||
@@ -85,6 +90,7 @@ function createFinishNotificationScenario(
|
|||||||
Reflect.set(agentManager, "tryRunOutOfBand", () => false);
|
Reflect.set(agentManager, "tryRunOutOfBand", () => false);
|
||||||
Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError));
|
Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError));
|
||||||
Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => {
|
Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => {
|
||||||
|
parentPrompted = true;
|
||||||
resolveParentPrompt?.(prompt);
|
resolveParentPrompt?.(prompt);
|
||||||
return (async function* noop() {})();
|
return (async function* noop() {})();
|
||||||
});
|
});
|
||||||
@@ -96,7 +102,12 @@ function createFinishNotificationScenario(
|
|||||||
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
|
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
|
||||||
Reflect.set(agentStorage, "get", async (agentId: string) => {
|
Reflect.set(agentStorage, "get", async (agentId: string) => {
|
||||||
if (agentId === "child-agent") {
|
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;
|
return null;
|
||||||
});
|
});
|
||||||
@@ -108,14 +119,11 @@ function createFinishNotificationScenario(
|
|||||||
agentStorage,
|
agentStorage,
|
||||||
childAgentId: "child-agent",
|
childAgentId: "child-agent",
|
||||||
callerAgentId: "caller-agent",
|
callerAgentId: "caller-agent",
|
||||||
|
requireParentOwnership: options?.requireParentOwnership,
|
||||||
logger: options?.logger ?? createTestLogger(),
|
logger: options?.logger ?? createTestLogger(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async finishChildAndReadParentPrompt() {
|
finishChild() {
|
||||||
const parentPrompt = new Promise<string>((resolve) => {
|
|
||||||
resolveParentPrompt = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
childAgent.lifecycle = "running";
|
childAgent.lifecycle = "running";
|
||||||
subscriber?.({
|
subscriber?.({
|
||||||
type: "agent_state",
|
type: "agent_state",
|
||||||
@@ -127,9 +135,18 @@ function createFinishNotificationScenario(
|
|||||||
type: "agent_state",
|
type: "agent_state",
|
||||||
agent: childAgent,
|
agent: childAgent,
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
async finishChildAndReadParentPrompt() {
|
||||||
|
const parentPrompt = new Promise<string>((resolve) => {
|
||||||
|
resolveParentPrompt = resolve;
|
||||||
|
});
|
||||||
|
this.finishChild();
|
||||||
|
|
||||||
return parentPrompt;
|
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 () => {
|
test("finish notifications log a rejected parent prompt without an unhandled rejection", async () => {
|
||||||
const captured = createCapturedLogger();
|
const captured = createCapturedLogger();
|
||||||
const scenario = createFinishNotificationScenario({
|
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 { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||||
import type { AgentStorage } from "./agent-storage.js";
|
import type { AgentStorage } from "./agent-storage.js";
|
||||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||||
|
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||||
|
|
||||||
export type AgentUnarchiveController = Pick<AgentManager, "notifyAgentState" | "unarchiveSnapshot">;
|
export type AgentUnarchiveController = Pick<AgentManager, "notifyAgentState" | "unarchiveSnapshot">;
|
||||||
|
|
||||||
@@ -244,6 +245,7 @@ export interface SetupFinishNotificationParams {
|
|||||||
agentStorage: AgentStorage;
|
agentStorage: AgentStorage;
|
||||||
childAgentId: string;
|
childAgentId: string;
|
||||||
callerAgentId: string;
|
callerAgentId: string;
|
||||||
|
requireParentOwnership?: boolean;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +266,14 @@ function formatFinishNotificationBody(params: FinishNotificationBodyInput): stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
|
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 hasSeenRunning = false;
|
||||||
let fired = false;
|
let fired = false;
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
@@ -282,6 +291,9 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const record = await agentStorage.get(childAgentId);
|
const record = await agentStorage.get(childAgentId);
|
||||||
|
if (requireParentOwnership && getParentAgentIdFromLabels(record?.labels) !== callerAgentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const title = record?.title ?? childAgentId;
|
const title = record?.title ?? childAgentId;
|
||||||
const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId);
|
const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId);
|
||||||
const body = formatFinishNotificationBody({
|
const body = formatFinishNotificationBody({
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Logger } from "pino";
|
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 { TerminalManager } from "../../../terminal/terminal-manager.js";
|
||||||
import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js";
|
import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js";
|
||||||
import { expandUserPath, resolvePathFromBase } from "../../path-utils.js";
|
import { expandUserPath, resolvePathFromBase } from "../../path-utils.js";
|
||||||
@@ -26,6 +25,7 @@ import {
|
|||||||
appendTimelineItemIfAgentKnown,
|
appendTimelineItemIfAgentKnown,
|
||||||
emitLiveTimelineItemIfAgentKnown,
|
emitLiveTimelineItemIfAgentKnown,
|
||||||
} from "../timeline-append.js";
|
} from "../timeline-append.js";
|
||||||
|
import { resolveCreateAgentIntent } from "./intent.js";
|
||||||
|
|
||||||
export interface CreateAgentSessionWorktreeResult {
|
export interface CreateAgentSessionWorktreeResult {
|
||||||
sessionConfig: AgentSessionConfig;
|
sessionConfig: AgentSessionConfig;
|
||||||
@@ -208,6 +208,7 @@ export async function createAgentCommand(
|
|||||||
agentStorage: dependencies.agentStorage,
|
agentStorage: dependencies.agentStorage,
|
||||||
childAgentId: snapshot.id,
|
childAgentId: snapshot.id,
|
||||||
callerAgentId: input.callerAgentId,
|
callerAgentId: input.callerAgentId,
|
||||||
|
requireParentOwnership: true,
|
||||||
logger: dependencies.logger,
|
logger: dependencies.logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -317,13 +318,21 @@ async function resolveMcpCreateAgent(
|
|||||||
});
|
});
|
||||||
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
|
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
|
||||||
|
|
||||||
const workspaceId = await resolveMcpWorkspaceId({
|
const intent = await resolveCreateAgentIntent({
|
||||||
dependencies,
|
explicitWorkspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId,
|
||||||
input,
|
caller: parentAgent
|
||||||
parentAgent,
|
? { id: parentAgent.id, cwd: parentAgent.cwd, workspaceId: parentAgent.workspaceId }
|
||||||
setupContinuation,
|
: null,
|
||||||
createdWorkspaceId,
|
labels: input.labels,
|
||||||
resolvedCwd,
|
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({
|
const resolvedCreateConfig = await resolveMcpProviderCreateConfig({
|
||||||
dependencies,
|
dependencies,
|
||||||
@@ -333,27 +342,20 @@ async function resolveMcpCreateAgent(
|
|||||||
parentAgent,
|
parentAgent,
|
||||||
});
|
});
|
||||||
|
|
||||||
const labels = mergeLabels({
|
|
||||||
callerAgentId: input.callerAgentId,
|
|
||||||
detached: input.detached ?? false,
|
|
||||||
childAgentDefaultLabels: input.callerContext?.childAgentDefaultLabels,
|
|
||||||
labels: input.labels,
|
|
||||||
});
|
|
||||||
|
|
||||||
const trimmedPrompt = input.initialPrompt?.trim() ?? "";
|
const trimmedPrompt = input.initialPrompt?.trim() ?? "";
|
||||||
return {
|
return {
|
||||||
config: buildMcpSessionConfig({
|
config: buildMcpSessionConfig({
|
||||||
input,
|
input,
|
||||||
resolvedProviderModel,
|
resolvedProviderModel,
|
||||||
provider,
|
provider,
|
||||||
resolvedCwd,
|
resolvedCwd: intent.cwd,
|
||||||
trimmedPrompt,
|
trimmedPrompt,
|
||||||
resolvedMode: resolvedCreateConfig.modeId,
|
resolvedMode: resolvedCreateConfig.modeId,
|
||||||
resolvedFeatures: resolvedCreateConfig.featureValues,
|
resolvedFeatures: resolvedCreateConfig.featureValues,
|
||||||
}),
|
}),
|
||||||
createOptions: {
|
createOptions: {
|
||||||
...(labels ? { labels } : {}),
|
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
|
||||||
workspaceId: requireResolvedWorkspaceId(workspaceId),
|
workspaceId: intent.workspaceId,
|
||||||
owner: input.owner,
|
owner: input.owner,
|
||||||
env: input.env,
|
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: {
|
async function resolveMcpProviderCreateConfig(params: {
|
||||||
dependencies: CreateAgentCommandDependencies;
|
dependencies: CreateAgentCommandDependencies;
|
||||||
input: CreateAgentFromMcpInput;
|
input: CreateAgentFromMcpInput;
|
||||||
@@ -624,22 +598,3 @@ async function createMcpWorktree(
|
|||||||
throw toWorktreeRequestError(error);
|
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);
|
).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 () => {
|
it("rejects partial explicit workspace shape", async () => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
@@ -1508,7 +1544,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
expect(parsed.success).toBe(true);
|
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 { agentManager, agentStorage } = createTestDeps();
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
@@ -1516,31 +1552,14 @@ describe("create_agent MCP tool", () => {
|
|||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_worktree");
|
expect(lookupTool(server, "create_workspace")).toBeDefined();
|
||||||
|
expect(lookupTool(server, "list_workspaces")).toBeDefined();
|
||||||
for (const target of [
|
expect(lookupTool(server, "archive_workspace")).toBeDefined();
|
||||||
{ kind: "branch-off", worktreeSlug: "feature-x", baseBranch: "main" },
|
expect(lookupTool(server, "create_worktree")).toBeUndefined();
|
||||||
{ kind: "branch-off" },
|
expect(lookupTool(server, "list_worktrees")).toBeUndefined();
|
||||||
{ kind: "checkout-branch", branch: "head-ref" },
|
expect(lookupTool(server, "archive_worktree")).toBeUndefined();
|
||||||
{ kind: "checkout-pr", githubPrNumber: 42 },
|
expect(lookupTool(server, "detach_agent")).toBeUndefined();
|
||||||
] as const) {
|
expect(lookupTool(server, "update_heartbeat")).toBeUndefined();
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces createAgent validation failures", async () => {
|
it("surfaces createAgent validation failures", async () => {
|
||||||
@@ -2443,7 +2462,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
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 { agentManager, agentStorage } = createTestDeps();
|
||||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-"));
|
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-"));
|
||||||
const repoDir = join(tempDir, "repo");
|
const repoDir = join(tempDir, "repo");
|
||||||
@@ -2488,25 +2507,19 @@ describe("create_agent MCP tool", () => {
|
|||||||
>,
|
>,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_worktree");
|
const tool = registeredTool(server, "create_workspace");
|
||||||
const response = await tool.handler({
|
const response = await tool.handler({
|
||||||
cwd: repoDir,
|
isolation: "worktree",
|
||||||
target: {
|
path: repoDir,
|
||||||
kind: "branch-off",
|
worktreeSlug: "tool-worktree",
|
||||||
worktreeSlug: "tool-worktree",
|
branchName: "feature/tool-worktree",
|
||||||
branchName: "feature/tool-worktree",
|
baseBranch: "main",
|
||||||
baseBranch: "main",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.structuredContent.branchName).toBe("feature/tool-worktree");
|
expect(response.structuredContent.isolation).toBe("worktree");
|
||||||
expect(response.structuredContent.worktreePath).toContain("tool-worktree");
|
expect(response.structuredContent.cwd).toContain("tool-worktree");
|
||||||
expect(response.structuredContent.workspaceId).toBe(broadcasts[0]);
|
expect(response.structuredContent.workspaceId).toBe(broadcasts[0]);
|
||||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
|
|
||||||
force: true,
|
|
||||||
reason: "mcp:create-worktree",
|
|
||||||
});
|
|
||||||
expect(setupContinuations).toEqual([undefined]);
|
expect(setupContinuations).toEqual([undefined]);
|
||||||
expect(broadcasts).toHaveLength(1);
|
expect(broadcasts).toHaveLength(1);
|
||||||
expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
|
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 { agentManager, agentStorage } = createTestDeps();
|
||||||
const tempDir = realpathSync.native(
|
const tempDir = realpathSync.native(
|
||||||
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")),
|
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")),
|
||||||
@@ -2568,13 +2723,15 @@ describe("create_agent MCP tool", () => {
|
|||||||
github: createGitHubServiceStub(),
|
github: createGitHubServiceStub(),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const createTool = registeredTool(server, "create_worktree");
|
const createTool = registeredTool(server, "create_workspace");
|
||||||
const archiveTool = registeredTool(server, "archive_worktree");
|
const archiveTool = registeredTool(server, "archive_workspace");
|
||||||
const created = await createTool.handler({
|
const created = await createTool.handler({
|
||||||
cwd: repoDir,
|
isolation: "worktree",
|
||||||
target: { kind: "branch-off", worktreeSlug: "archive-tool-worktree", baseBranch: "main" },
|
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 () => [
|
listActiveWorkspaces.mockImplementation(async () => [
|
||||||
{ workspaceId: "ws-archive-tool-worktree", cwd: createdWorktreePath, kind: "worktree" },
|
{ workspaceId: "ws-archive-tool-worktree", cwd: createdWorktreePath, kind: "worktree" },
|
||||||
]);
|
]);
|
||||||
@@ -2584,19 +2741,13 @@ describe("create_agent MCP tool", () => {
|
|||||||
workspaceGitService.getSnapshot.mockClear();
|
workspaceGitService.getSnapshot.mockClear();
|
||||||
|
|
||||||
await archiveTool.handler({
|
await archiveTool.handler({
|
||||||
cwd: repoDir,
|
workspaceId: "ws-archive-tool-worktree",
|
||||||
worktreePath: created.structuredContent.worktreePath,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
|
||||||
force: true,
|
force: true,
|
||||||
reason: "archive-worktree",
|
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(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree");
|
||||||
expect(markWorkspaceArchiving).toHaveBeenCalledWith(
|
expect(markWorkspaceArchiving).toHaveBeenCalledWith(
|
||||||
["ws-archive-tool-worktree"],
|
["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 { agentManager, agentStorage } = createTestDeps();
|
||||||
const tempDir = realpathSync.native(
|
const tempDir = realpathSync.native(
|
||||||
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-multi-")),
|
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-multi-")),
|
||||||
@@ -2670,13 +2836,15 @@ describe("create_agent MCP tool", () => {
|
|||||||
github: createGitHubServiceStub(),
|
github: createGitHubServiceStub(),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const createTool = registeredTool(server, "create_worktree");
|
const createTool = registeredTool(server, "create_workspace");
|
||||||
const archiveTool = registeredTool(server, "archive_worktree");
|
const archiveTool = registeredTool(server, "archive_workspace");
|
||||||
const created = await createTool.handler({
|
const created = await createTool.handler({
|
||||||
cwd: repoDir,
|
isolation: "worktree",
|
||||||
target: { kind: "branch-off", worktreeSlug: "archive-multi-worktree", baseBranch: "main" },
|
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
|
// Populate the active workspaces with the real created path so archiveByScope
|
||||||
// matches it against the worktree directory.
|
// matches it against the worktree directory.
|
||||||
@@ -2686,19 +2854,18 @@ describe("create_agent MCP tool", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
await archiveTool.handler({
|
await archiveTool.handler({
|
||||||
cwd: repoDir,
|
workspaceId: "ws-mcp-A",
|
||||||
worktreePath,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(archivedWorkspaceIds).toContain("ws-mcp-A");
|
expect(archivedWorkspaceIds).toContain("ws-mcp-A");
|
||||||
expect(archivedWorkspaceIds).toContain("ws-mcp-B");
|
expect(archivedWorkspaceIds).not.toContain("ws-mcp-B");
|
||||||
await expect(access(worktreePath)).rejects.toThrow();
|
await expect(access(worktreePath)).resolves.toBeUndefined();
|
||||||
} finally {
|
} finally {
|
||||||
await removeTempDir(tempDir);
|
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 { agentManager, agentStorage } = createTestDeps();
|
||||||
const tempDir = realpathSync.native(
|
const tempDir = realpathSync.native(
|
||||||
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-slug-")),
|
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-slug-")),
|
||||||
@@ -2746,71 +2913,41 @@ describe("create_agent MCP tool", () => {
|
|||||||
github: createGitHubServiceStub(),
|
github: createGitHubServiceStub(),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const createTool = registeredTool(server, "create_worktree");
|
expect(lookupTool(server, "create_worktree")).toBeUndefined();
|
||||||
const archiveTool = registeredTool(server, "archive_worktree");
|
expect(lookupTool(server, "archive_worktree")).toBeUndefined();
|
||||||
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();
|
|
||||||
} finally {
|
} finally {
|
||||||
await removeTempDir(tempDir);
|
await removeTempDir(tempDir);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes list_worktrees through WorkspaceGitService", async () => {
|
it("lists active workspace descriptors", async () => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const workspaceGitService = {
|
const workspace = createPersistedWorkspaceRecord({
|
||||||
getSnapshot: vi.fn(async () => null),
|
workspaceId: "ws-feature",
|
||||||
listWorktrees: vi.fn(async () => [
|
projectId: "project-1",
|
||||||
{
|
cwd: "/tmp/paseo/worktrees/repo/feature",
|
||||||
path: "/tmp/paseo/worktrees/repo/feature",
|
kind: "worktree",
|
||||||
branchName: "feature",
|
displayName: "feature",
|
||||||
createdAt: "2026-04-12T00:00:00.000Z",
|
createdAt: "2026-07-17T00:00:00.000Z",
|
||||||
},
|
updatedAt: "2026-07-17T00:00:00.000Z",
|
||||||
]),
|
});
|
||||||
};
|
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
workspaceRegistry: {
|
||||||
WorkspaceGitService,
|
get: vi.fn(async () => workspace),
|
||||||
"getSnapshot" | "listWorktrees"
|
list: vi.fn(async () => [workspace]),
|
||||||
>,
|
upsert: vi.fn(async () => undefined),
|
||||||
|
},
|
||||||
logger,
|
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, {
|
expect(response.structuredContent.workspaces).toEqual([
|
||||||
reason: "mcp:list-worktrees",
|
expect.objectContaining({ workspaceId: "ws-feature", isolation: "worktree" }),
|
||||||
});
|
|
||||||
expect(response.structuredContent.worktrees).toEqual([
|
|
||||||
{
|
|
||||||
path: "/tmp/paseo/worktrees/repo/feature",
|
|
||||||
branchName: "feature",
|
|
||||||
createdAt: "2026-04-12T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3835,11 +3972,11 @@ describe("create_schedule MCP tool", () => {
|
|||||||
cron: "*/5 * * * *",
|
cron: "*/5 * * * *",
|
||||||
name: "Default schedule",
|
name: "Default schedule",
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("provider is required when target is new-agent");
|
).rejects.toThrow("provider");
|
||||||
expect(createOrReplace).not.toHaveBeenCalled();
|
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 { agentManager, agentStorage } = createTestDeps();
|
||||||
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
||||||
createStoredSchedule(input),
|
createStoredSchedule(input),
|
||||||
@@ -3863,6 +4000,12 @@ describe("create_schedule MCP tool", () => {
|
|||||||
cron: "*/10 * * * *",
|
cron: "*/10 * * * *",
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
});
|
});
|
||||||
|
await tool.handler({
|
||||||
|
prompt: "say hello in a worktree",
|
||||||
|
cron: "*/15 * * * *",
|
||||||
|
provider: "codex",
|
||||||
|
isolation: "worktree",
|
||||||
|
});
|
||||||
|
|
||||||
expect(createOrReplace).toHaveBeenNthCalledWith(
|
expect(createOrReplace).toHaveBeenNthCalledWith(
|
||||||
1,
|
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();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.getAgent.mockReturnValue({
|
spies.agentManager.getAgent.mockReturnValue({
|
||||||
id: "parent-agent",
|
id: "parent-agent",
|
||||||
@@ -3922,12 +4078,17 @@ describe("create_schedule MCP tool", () => {
|
|||||||
const response = await tool.handler({
|
const response = await tool.handler({
|
||||||
prompt: "say hello",
|
prompt: "say hello",
|
||||||
cron: "*/5 * * * *",
|
cron: "*/5 * * * *",
|
||||||
provider: "opencode/openai/gpt-5.5",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.structuredContent.target).toMatchObject({
|
expect(response.structuredContent.target).toEqual({
|
||||||
type: "new-agent",
|
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", () => {
|
describe("update_schedule MCP tool", () => {
|
||||||
const logger = createTestLogger();
|
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 () => {
|
it("calls scheduleService.update with correct input", async () => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const stored = makeStoredSchedule();
|
const stored = makeStoredSchedule();
|
||||||
@@ -4138,7 +4363,7 @@ describe("update_schedule MCP tool", () => {
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { update } as unknown as ScheduleService,
|
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "update_schedule");
|
const tool = registeredTool(server, "update_schedule");
|
||||||
@@ -4164,7 +4389,7 @@ describe("update_schedule MCP tool", () => {
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { update } as unknown as ScheduleService,
|
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "update_schedule");
|
const tool = registeredTool(server, "update_schedule");
|
||||||
@@ -4176,7 +4401,7 @@ describe("update_schedule MCP tool", () => {
|
|||||||
|
|
||||||
expect(update).toHaveBeenCalledWith({
|
expect(update).toHaveBeenCalledWith({
|
||||||
id: "schedule-1",
|
id: "schedule-1",
|
||||||
cadence: { type: "every", everyMs: 600000 },
|
cadence: { type: "cron", expression: "*/10 * * * *" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4188,7 +4413,7 @@ describe("update_schedule MCP tool", () => {
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { update } as unknown as ScheduleService,
|
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "update_schedule");
|
const tool = registeredTool(server, "update_schedule");
|
||||||
@@ -4217,7 +4442,7 @@ describe("update_schedule MCP tool", () => {
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { update } as unknown as ScheduleService,
|
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "update_schedule");
|
const tool = registeredTool(server, "update_schedule");
|
||||||
@@ -4230,7 +4455,7 @@ describe("update_schedule MCP tool", () => {
|
|||||||
|
|
||||||
expect(update).toHaveBeenCalledWith({
|
expect(update).toHaveBeenCalledWith({
|
||||||
id: "schedule-1",
|
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",
|
label: "whitespace cron field",
|
||||||
input: { id: "schedule-1", every: "10m", cron: " " },
|
input: { id: "schedule-1", every: "10m", cron: " " },
|
||||||
cadence: { type: "every", everyMs: 600000 },
|
cadence: { type: "cron", expression: "*/10 * * * *" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "blank every field for cron cadence",
|
label: "blank every field for cron cadence",
|
||||||
@@ -4258,7 +4483,7 @@ describe("update_schedule MCP tool", () => {
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { update } as unknown as ScheduleService,
|
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "update_schedule");
|
const tool = registeredTool(server, "update_schedule");
|
||||||
@@ -4278,7 +4503,7 @@ describe("update_schedule MCP tool", () => {
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { update } as unknown as ScheduleService,
|
scheduleService: scheduleServiceWithUpdate(update),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "update_schedule");
|
const tool = registeredTool(server, "update_schedule");
|
||||||
@@ -4300,7 +4525,7 @@ describe("update_schedule MCP tool", () => {
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { update } as unknown as ScheduleService,
|
scheduleService: scheduleServiceWithUpdate(update),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "update_schedule");
|
const tool = registeredTool(server, "update_schedule");
|
||||||
@@ -4323,7 +4548,7 @@ describe("update_schedule MCP tool", () => {
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { update } as unknown as ScheduleService,
|
scheduleService: scheduleServiceWithUpdate(update),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "update_schedule");
|
const tool = registeredTool(server, "update_schedule");
|
||||||
@@ -4347,7 +4572,7 @@ describe("update_schedule MCP tool", () => {
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { update } as unknown as ScheduleService,
|
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "update_schedule");
|
const tool = registeredTool(server, "update_schedule");
|
||||||
@@ -4381,7 +4606,7 @@ describe("update_schedule MCP tool", () => {
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { update } as unknown as ScheduleService,
|
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "update_schedule");
|
const tool = registeredTool(server, "update_schedule");
|
||||||
@@ -4412,7 +4637,7 @@ describe("update_schedule MCP tool", () => {
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { update } as unknown as ScheduleService,
|
scheduleService: scheduleServiceWithUpdate(update),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "update_schedule");
|
const tool = registeredTool(server, "update_schedule");
|
||||||
@@ -4455,11 +4680,15 @@ describe("schedule_logs MCP tool", () => {
|
|||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const runs = [makeRun({ id: "run-1" }), makeRun({ id: "run-2", status: "failed" })];
|
const runs = [makeRun({ id: "run-1" }), makeRun({ id: "run-2", status: "failed" })];
|
||||||
const logs = vi.fn(async (_id: string) => runs);
|
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({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { logs } as unknown as ScheduleService,
|
scheduleService: { logs, inspect } as unknown as ScheduleService,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "schedule_logs");
|
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,
|
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
|
projectRegistry,
|
||||||
|
createDirectoryWorkspace: async (cwd, title, projectId) => {
|
||||||
|
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||||
|
cwd,
|
||||||
|
title,
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
|
await emitWorkspaceUpdatesExternal([workspace.workspaceId]);
|
||||||
|
return workspace;
|
||||||
|
},
|
||||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||||
ensureWorkspaceForCreate: createAgentCommandDependencies.ensureWorkspaceForCreate,
|
ensureWorkspaceForCreate: createAgentCommandDependencies.ensureWorkspaceForCreate,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
|||||||
import { DaemonClient } from "./test-utils/index.js";
|
import { DaemonClient } from "./test-utils/index.js";
|
||||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||||
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.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
|
// The daemon-level workspace contract that `paseo run` depends on: each
|
||||||
// local-backed createWorkspace for a cwd mints a fresh, distinct workspace,
|
// 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;
|
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 daemon = await createTestPaseoDaemon();
|
||||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-cwd-"));
|
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({
|
const client = new DaemonClient({
|
||||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||||
appVersion: "0.1.82",
|
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 });
|
const fetchedFirst = await client.fetchAgent({ agentId: firstAgent.id });
|
||||||
expect(fetchedFirst?.agent.workspaceId).toBe(firstWorkspaceId);
|
expect(fetchedFirst?.agent.workspaceId).toBe(firstWorkspaceId);
|
||||||
|
|
||||||
// A second bare run in the SAME cwd mints a DISTINCT workspace; each run
|
// A second bare run mints a distinct workspace with its own authoritative cwd.
|
||||||
// owns its own workspace rather than reattaching to the first.
|
const secondWorkspaceId = await mintLocalWorkspace(client, otherCwd);
|
||||||
const secondWorkspaceId = await mintLocalWorkspace(client, cwd);
|
|
||||||
expect(secondWorkspaceId).not.toBe(firstWorkspaceId);
|
expect(secondWorkspaceId).not.toBe(firstWorkspaceId);
|
||||||
|
|
||||||
const secondAgent = await client.createAgent({
|
const secondAgent = await client.createAgent({
|
||||||
...getFullAccessConfig("codex"),
|
...getFullAccessConfig("codex"),
|
||||||
cwd,
|
cwd: otherCwd,
|
||||||
workspaceId: secondWorkspaceId,
|
workspaceId: secondWorkspaceId,
|
||||||
title: "Second run agent",
|
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 idsBeforeAttach = await workspaceIds(client);
|
||||||
const attachedAgent = await client.createAgent({
|
const attachedAgent = await client.createAgent({
|
||||||
...getFullAccessConfig("codex"),
|
...getFullAccessConfig("codex"),
|
||||||
cwd,
|
cwd: path.join(otherCwd, "stale-client-directory"),
|
||||||
workspaceId: firstWorkspaceId,
|
workspaceId: firstWorkspaceId,
|
||||||
title: "Attached agent",
|
title: "Attached agent",
|
||||||
});
|
});
|
||||||
expect(attachedAgent.workspaceId).toBe(firstWorkspaceId);
|
expect(attachedAgent.workspaceId).toBe(firstWorkspaceId);
|
||||||
|
expect(attachedAgent.cwd).toBe(cwd);
|
||||||
expect(await workspaceIds(client)).toEqual(idsBeforeAttach);
|
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 {
|
} finally {
|
||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
await daemon.close();
|
await daemon.close();
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
rmSync(otherCwd, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}, 180000);
|
}, 180000);
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ test("creates a worktree and registers it in the source workspace project withou
|
|||||||
{
|
{
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
worktreeSlug: "feature-one",
|
worktreeSlug: "feature-one",
|
||||||
|
title: "Feature One",
|
||||||
runSetup: false,
|
runSetup: false,
|
||||||
paseoHome: path.join(tempDir, ".paseo"),
|
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.projectId).toBe("remote:github.com/acme/repo");
|
||||||
expect(result.workspace.displayName).toBe("feature-one");
|
expect(result.workspace.displayName).toBe("feature-one");
|
||||||
expect(result.workspace.baseBranch).toBe("main");
|
expect(result.workspace.baseBranch).toBe("main");
|
||||||
|
expect(result.workspace.title).toBe("Feature One");
|
||||||
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||||
expect(deps.projects.get(sourceProject.projectId)).toEqual(sourceProject);
|
expect(deps.projects.get(sourceProject.projectId)).toEqual(sourceProject);
|
||||||
expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]);
|
expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import type { FirstAgentContext } from "@getpaseo/protocol/messages";
|
|||||||
|
|
||||||
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePaseoWorktreeResult {
|
export interface CreatePaseoWorktreeResult {
|
||||||
@@ -89,7 +90,7 @@ export async function createPaseoWorktree(
|
|||||||
worktreeRoot: createdWorktree.worktree.worktreePath,
|
worktreeRoot: createdWorktree.worktree.worktreePath,
|
||||||
branch: createdWorktree.worktree.branchName || null,
|
branch: createdWorktree.worktree.branchName || null,
|
||||||
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
|
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
|
||||||
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
|
title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||||
});
|
});
|
||||||
|
|
||||||
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
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 {
|
export function computeNextRunAt(cadence: ScheduleCadence, after: Date): Date {
|
||||||
if (cadence.type === "every") {
|
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);
|
return new Date(after.getTime() + cadence.everyMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import type {
|
|||||||
ManagedAgent,
|
ManagedAgent,
|
||||||
} from "./agent/agent-manager.js";
|
} from "./agent/agent-manager.js";
|
||||||
import { createAgentCommand } from "./agent/create-agent/create.js";
|
import { createAgentCommand } from "./agent/create-agent/create.js";
|
||||||
|
import { resolveCreateAgentIntent, type CreateAgentIntent } from "./agent/create-agent/intent.js";
|
||||||
import {
|
import {
|
||||||
archiveAgentCommand,
|
archiveAgentCommand,
|
||||||
cancelAgentRunCommand,
|
cancelAgentRunCommand,
|
||||||
@@ -160,6 +161,7 @@ import { PushTokenStore } from "./push/token-store.js";
|
|||||||
import {
|
import {
|
||||||
archivePersistedWorkspaceRecord,
|
archivePersistedWorkspaceRecord,
|
||||||
archiveWorkspaceContents,
|
archiveWorkspaceContents,
|
||||||
|
requireActiveWorkspaceForArchive,
|
||||||
} from "./workspace-archive-service.js";
|
} from "./workspace-archive-service.js";
|
||||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||||
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
||||||
@@ -236,6 +238,7 @@ import {
|
|||||||
} from "./project-directory-service.js";
|
} from "./project-directory-service.js";
|
||||||
import { runGitCommand } from "../utils/run-git-command.js";
|
import { runGitCommand } from "../utils/run-git-command.js";
|
||||||
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.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.
|
// 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
|
// 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 FetchAgentsResponseEntry = FetchAgentsResponsePayload["entries"][number];
|
||||||
type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload["pageInfo"];
|
type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload["pageInfo"];
|
||||||
type AgentUpdatesFilter = FetchAgentsRequestFilter;
|
type AgentUpdatesFilter = FetchAgentsRequestFilter;
|
||||||
|
type CreateAgentRequestMessage = Extract<SessionInboundMessage, { type: "create_agent_request" }>;
|
||||||
|
|
||||||
|
interface ResolvedSessionCreateAgentIntent {
|
||||||
|
config: AgentSessionConfig;
|
||||||
|
intent: CreateAgentIntent;
|
||||||
|
createdDirectoryWorkspace: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
type FetchWorkspacesRequestMessage = Extract<
|
type FetchWorkspacesRequestMessage = Extract<
|
||||||
SessionInboundMessage,
|
SessionInboundMessage,
|
||||||
{ type: "fetch_workspaces_request" }
|
{ type: "fetch_workspaces_request" }
|
||||||
@@ -2778,9 +2789,7 @@ export class Session {
|
|||||||
/**
|
/**
|
||||||
* Handle create agent request
|
* Handle create agent request
|
||||||
*/
|
*/
|
||||||
private async handleCreateAgentRequest(
|
private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise<void> {
|
||||||
msg: Extract<SessionInboundMessage, { type: "create_agent_request" }>,
|
|
||||||
): Promise<void> {
|
|
||||||
const {
|
const {
|
||||||
config,
|
config,
|
||||||
worktreeName,
|
worktreeName,
|
||||||
@@ -2793,7 +2802,6 @@ export class Session {
|
|||||||
autoArchive,
|
autoArchive,
|
||||||
images,
|
images,
|
||||||
attachments,
|
attachments,
|
||||||
labels,
|
|
||||||
env,
|
env,
|
||||||
} = msg;
|
} = msg;
|
||||||
this.sessionLogger.info(
|
this.sessionLogger.info(
|
||||||
@@ -2807,7 +2815,9 @@ export class Session {
|
|||||||
let createdAgentId: string | null = null;
|
let createdAgentId: string | null = null;
|
||||||
try {
|
try {
|
||||||
const requestedCwd = resolve(config.cwd);
|
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}`);
|
throw new Error(`Working directory does not exist or is not a directory: ${requestedCwd}`);
|
||||||
}
|
}
|
||||||
const trimmedPrompt = initialPrompt?.trim();
|
const trimmedPrompt = initialPrompt?.trim();
|
||||||
@@ -2828,18 +2838,15 @@ export class Session {
|
|||||||
hasLegacyGitOptions: Boolean(git),
|
hasLegacyGitOptions: Boolean(git),
|
||||||
});
|
});
|
||||||
createdWorktreeForCleanup = createdWorktree;
|
createdWorktreeForCleanup = createdWorktree;
|
||||||
const createAgentConfig: AgentSessionConfig = createdWorktree
|
const resolvedIntent = await this.resolveSessionCreateAgentIntent({
|
||||||
? { ...config, cwd: createdWorktree.workspace.cwd }
|
request: msg,
|
||||||
: config;
|
createdWorktree,
|
||||||
const workspaceId = await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent(
|
workspacePromptTitle,
|
||||||
{
|
});
|
||||||
createdWorktree,
|
const resolvedCwd = resolve(resolvedIntent.config.cwd);
|
||||||
requestedWorkspaceId: msg.workspaceId,
|
if (!(await this.filesystem.isDirectory(resolvedCwd))) {
|
||||||
cwd: createAgentConfig.cwd,
|
throw new Error(`Working directory does not exist or is not a directory: ${resolvedCwd}`);
|
||||||
initialTitle: workspacePromptTitle,
|
}
|
||||||
},
|
|
||||||
);
|
|
||||||
const createdDirectoryWorkspaceForAgent = !createdWorktree && !msg.workspaceId;
|
|
||||||
|
|
||||||
const { snapshot, liveSnapshot } = await createAgentCommand(
|
const { snapshot, liveSnapshot } = await createAgentCommand(
|
||||||
{
|
{
|
||||||
@@ -2852,8 +2859,8 @@ export class Session {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "session",
|
kind: "session",
|
||||||
config: createAgentConfig,
|
config: resolvedIntent.config,
|
||||||
workspaceId,
|
workspaceId: resolvedIntent.intent.workspaceId,
|
||||||
worktreeName,
|
worktreeName,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
clientMessageId,
|
clientMessageId,
|
||||||
@@ -2861,7 +2868,7 @@ export class Session {
|
|||||||
images,
|
images,
|
||||||
attachments,
|
attachments,
|
||||||
git,
|
git,
|
||||||
labels,
|
labels: resolvedIntent.intent.labels,
|
||||||
env,
|
env,
|
||||||
provisionalTitle,
|
provisionalTitle,
|
||||||
firstAgentContext,
|
firstAgentContext,
|
||||||
@@ -2871,14 +2878,14 @@ export class Session {
|
|||||||
);
|
);
|
||||||
createdAgentId = snapshot.id;
|
createdAgentId = snapshot.id;
|
||||||
await this.agentUpdates.forwardLiveAgent(snapshot);
|
await this.agentUpdates.forwardLiveAgent(snapshot);
|
||||||
if (createdDirectoryWorkspaceForAgent && trimmedPrompt) {
|
if (resolvedIntent.createdDirectoryWorkspace && trimmedPrompt) {
|
||||||
this.workspaceAutoName.scheduleForDirectory(
|
this.workspaceAutoName.scheduleForDirectory(
|
||||||
{
|
{
|
||||||
workspaceId,
|
workspaceId: resolvedIntent.intent.workspaceId,
|
||||||
cwd: createAgentConfig.cwd,
|
cwd: resolvedIntent.config.cwd,
|
||||||
firstAgentContext,
|
firstAgentContext,
|
||||||
},
|
},
|
||||||
{ currentSelection: this.getFocusedAgentSelectionForCwd(createAgentConfig.cwd) },
|
{ currentSelection: this.getFocusedAgentSelectionForCwd(resolvedIntent.config.cwd) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({
|
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(
|
private async handleResumeAgentRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "resume_agent_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "resume_agent_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -4874,10 +4930,7 @@ export class Session {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceCwd = await this.resolveWorktreeSourceCwd({
|
const sourceCwd = await resolveWorktreeSourceCwd(source, this.projectRegistry);
|
||||||
cwd: source.cwd,
|
|
||||||
projectId: source.projectId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await this.createPaseoWorktreeWorkflow(
|
const result = await this.createPaseoWorktreeWorkflow(
|
||||||
{
|
{
|
||||||
@@ -4886,24 +4939,17 @@ export class Session {
|
|||||||
worktreeSlug: source.worktreeSlug,
|
worktreeSlug: source.worktreeSlug,
|
||||||
action: source.action,
|
action: source.action,
|
||||||
refName: source.refName,
|
refName: source.refName,
|
||||||
|
branchName: source.branchName,
|
||||||
checkoutSource: source.checkoutSource,
|
checkoutSource: source.checkoutSource,
|
||||||
githubPrNumber: source.githubPrNumber,
|
githubPrNumber: source.githubPrNumber,
|
||||||
firstAgentContext: request.firstAgentContext,
|
firstAgentContext: request.firstAgentContext,
|
||||||
|
title: request.title,
|
||||||
},
|
},
|
||||||
source.baseBranch
|
source.baseBranch
|
||||||
? { resolveDefaultBranch: async () => source.baseBranch as string }
|
? { resolveDefaultBranch: async () => source.baseBranch as string }
|
||||||
: undefined,
|
: 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);
|
const descriptor = await this.describeCreatedWorktreeWorkspace(result);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "workspace.create.response",
|
type: "workspace.create.response",
|
||||||
@@ -4917,20 +4963,6 @@ export class Session {
|
|||||||
await this.emitCreatedWorkspaceUpdate(descriptor);
|
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(
|
private async handleOpenProjectRequest(
|
||||||
request: Extract<SessionInboundMessage, { type: "open_project_request" }>,
|
request: Extract<SessionInboundMessage, { type: "open_project_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -5399,10 +5431,10 @@ export class Session {
|
|||||||
request: Extract<SessionInboundMessage, { type: "archive_workspace_request" }>,
|
request: Extract<SessionInboundMessage, { type: "archive_workspace_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const existing = await this.workspaceRegistry.get(request.workspaceId);
|
const existing = await requireActiveWorkspaceForArchive(
|
||||||
if (!existing) {
|
{ listActiveWorkspaces: () => this.listActiveWorkspaceRefs() },
|
||||||
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
request.workspaceId,
|
||||||
}
|
);
|
||||||
|
|
||||||
await archiveByScope(
|
await archiveByScope(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ export interface ArchiveByScopeRequest {
|
|||||||
requestId: string;
|
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 {
|
interface BackingDirectory {
|
||||||
path: string;
|
path: string;
|
||||||
isPaseoOwnedWorktree: boolean;
|
isPaseoOwnedWorktree: boolean;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { DaemonClient } from "./test-utils/index.js";
|
|||||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||||
|
|
||||||
// The reshaped workspace.create.request forwards its worktree `source`
|
// 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
|
// The regression these tests guard against is the daemon dropping action/refName
|
||||||
// while subsetting the request. We prove forwarding through the real workflow:
|
// while subsetting the request. We prove forwarding through the real workflow:
|
||||||
// the created worktree's observable branch is the only honest evidence the
|
// 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);
|
}, 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 daemon = await createTestPaseoDaemon();
|
||||||
const { repoDir, tempRoot } = createGitRepoWithBranch();
|
const { repoDir, tempRoot } = createGitRepoWithBranch();
|
||||||
const client = new DaemonClient({
|
const client = new DaemonClient({
|
||||||
@@ -80,15 +80,15 @@ test("workspace.create worktree source forwards branch-off + refName as the new
|
|||||||
kind: "worktree",
|
kind: "worktree",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
action: "branch-off",
|
action: "branch-off",
|
||||||
worktreeSlug: "brand-new-branch",
|
branchName: "feature/auth",
|
||||||
|
worktreeSlug: "feature-auth",
|
||||||
baseBranch: "main",
|
baseBranch: "main",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.error).toBeNull();
|
expect(result.error).toBeNull();
|
||||||
// branch-off cuts a new branch named after worktreeSlug from baseBranch; the
|
expect(result.workspace?.gitRuntime?.currentBranch).toBe("feature/auth");
|
||||||
// worktree landing on that branch proves worktreeSlug/baseBranch forwarded.
|
expect(path.basename(result.workspace?.workspaceDirectory ?? "")).toBe("feature-auth");
|
||||||
expect(result.workspace?.gitRuntime?.currentBranch).toBe("brand-new-branch");
|
|
||||||
} finally {
|
} finally {
|
||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
await daemon.close();
|
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
|
## Parsing arguments
|
||||||
|
|
||||||
1. **Provider** — explicit user request first; otherwise resolve from `impl` preference (or `ui` if the task is styling-only).
|
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.
|
3. **Task description** — anything else the user said.
|
||||||
|
|
||||||
## The handoff prompt
|
## The handoff prompt
|
||||||
@@ -54,18 +54,14 @@ The receiving agent has zero context. Include:
|
|||||||
|
|
||||||
## Launch
|
## 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" }`.
|
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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
Leave `notifyOnFinish` omitted unless the user explicitly wants no callback.
|
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).
|
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."
|
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
|
**User's arguments:** $ARGUMENTS
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|||||||
@@ -1,48 +1,29 @@
|
|||||||
---
|
---
|
||||||
name: paseo
|
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.
|
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 } }`.
|
**`list_workspaces`** — list active workspaces.
|
||||||
- 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" } }`.
|
|
||||||
|
|
||||||
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.
|
Worktree creation and reference accounting are implementation details of `isolation: "worktree"`.
|
||||||
|
|
||||||
**`list_worktrees`** — current repo (or pass `cwd`).
|
|
||||||
**`archive_worktree`** — `{ worktreePath }` or `{ worktreeSlug }`. Removes worktree and branch.
|
|
||||||
|
|
||||||
## Agents
|
## 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.
|
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
|
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.
|
||||||
|
|
||||||
`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 } } }`
|
|
||||||
|
|
||||||
Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` only for truly fire-and-forget agents.
|
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.
|
**`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
|
## Models
|
||||||
|
|
||||||
`claude/sonnet` (default), `claude/opus` (harder reasoning), `codex/gpt-5.4` (frontier coding), `claude/haiku` (tests only).
|
`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.
|
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
|
```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 send <agent-id> "<follow-up>"
|
||||||
paseo ls
|
paseo ls
|
||||||
paseo worktree ls
|
|
||||||
paseo schedule create --cron "*/15 * * * *" "ping main build"
|
paseo schedule create --cron "*/15 * * * *" "ping main build"
|
||||||
|
paseo heartbeat create --cron "*/15 * * * *" "check the build"
|
||||||
```
|
```
|
||||||
|
|
||||||
Discover with `paseo --help` and `paseo <cmd> --help`.
|
Discover with `paseo --help` and `paseo <cmd> --help`.
|
||||||
|
|||||||
Reference in New Issue
Block a user