From 7fb3dda20cc0e90b188f4f37e045da87e33fd01f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 1 Jun 2026 14:07:31 +0800 Subject: [PATCH] Add detached agents and heartbeat scheduling (#1266) --- docs/agent-lifecycle.md | 28 +- docs/glossary.md | 3 +- docs/release.md | 8 +- .../src/server/agent/create-agent/create.ts | 34 +- .../src/server/agent/mcp-parity.e2e.test.ts | 43 ++- .../src/server/agent/mcp-server.test.ts | 319 +++++++++++------- .../server/src/server/agent/mcp-server.ts | 231 ++++++++----- skills/paseo-advisor/SKILL.md | 2 +- skills/paseo-committee/SKILL.md | 10 +- skills/paseo-epic/SKILL.md | 2 + skills/paseo-handoff/SKILL.md | 8 +- skills/paseo-loop/SKILL.md | 2 +- skills/paseo/SKILL.md | 24 +- 13 files changed, 443 insertions(+), 271 deletions(-) diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 24177b5dc..12e7646d4 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -14,14 +14,14 @@ Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, ` ## Relationships -Agents can launch other agents via the `create_agent` MCP tool. When they do, the daemon stamps the new agent with a label `paseo.parent-agent-id` pointing back at the caller (`packages/server/src/server/agent/mcp-server.ts:804`). The client surfaces that as `agent.parentAgentId`. +Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. By default, the daemon stamps the created agent with a label `paseo.parent-agent-id` pointing back at the agent that created it. The client surfaces that as `agent.parentAgentId`. -There is exactly one relationship type today: `parentAgentId`. The daemon does not distinguish between: +Agent-scoped `create_agent` accepts `detached: true` for agents that should stand on their own. The daemon still uses the creating agent for cwd/config inheritance, but does not write `paseo.parent-agent-id`. -- **Subagents** — children that exist as part of the parent's work (e.g. orchestration tasks the parent delegates and waits on) -- **Detached agents** — children launched to take over from the parent (e.g. handoffs, fire-and-forget delegations) +- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it. +- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it. -Both look the same in storage. This is an accepted limitation — see [Limitations](#limitations). +`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents. ## Archive @@ -77,12 +77,6 @@ We considered universal decoupling (no tab close ever archives, archive is alway ## Limitations -### Detached agents are cascade-archived - -The daemon can't tell a "subagent" apart from a "detached agent" — both carry `paseo.parent-agent-id`. So when you archive an agent that previously launched a detached child (e.g. via `/paseo-handoff`), cascade will archive the detached child too, even though semantically it should outlive the originator. - -Until a richer relation model lands (e.g. a `relation: "subagent" | "detached"` field on creation, or a separate channel for handoff launches), this trade-off stands. Workaround: don't archive an agent whose work was handed off, or unarchive the detached child afterward. - ### Subagent accumulation under long-lived parents A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem. @@ -99,11 +93,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json Each agent is a single JSON file. Fields relevant to this doc: -| Field | Type | Meaning | -| --------------------------------- | ------------- | ------------------------------------------------------------- | -| `id` | `string` | Stable identifier | -| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) | -| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` MCP tool | -| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` | +| Field | Type | Meaning | +| --------------------------------- | ------------- | ----------------------------------------------------------------------------------------- | +| `id` | `string` | Stable identifier | +| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) | +| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by agent-scoped `create_agent` unless `detached: true` | +| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` | See [`docs/data-model.md`](./data-model.md) for the full agent record. diff --git a/docs/glossary.md b/docs/glossary.md index 68912d971..6fad3f79f 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -18,7 +18,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`). - **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`). - **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 remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/protocol/src/messages.ts`). Don't confuse with: 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. - **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`). - **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`). - **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent. diff --git a/docs/release.md b/docs/release.md index f2dfe3367..0bd6f09ef 100644 --- a/docs/release.md +++ b/docs/release.md @@ -232,18 +232,16 @@ To confirm the submission landed, inspect the EAS workflow with `npx eas workflo The user rarely opens the Expo dashboard. A failed EAS build or submit/review job can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks GitHub Actions, EAS builds, and the EAS `Release Mobile` workflow for the release tag. If any build is `ERRORED`/`CANCELED`, any workflow is `FAILURE`, or any required submit/review job fails, surface it immediately. If all builds are `FINISHED` and all required submit/review jobs are `SUCCESS`, confirm and stop. -**Use a heartbeat schedule, never a new-agent schedule.** Babysitting fires back into the current conversation as a wake-up prompt — `target: "self"` in `mcp__paseo__create_schedule`. Never use `target: "new-agent"`. A new agent spawns a fresh conversation the user has to find and read; a heartbeat surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `new-agent` for a release babysit, you are about to ship a status report into a void. +**Use `create_heartbeat`, never `create_schedule`, for release babysitting.** Babysitting fires back into the current conversation as a wake-up prompt. `create_schedule` starts a fresh agent the user has to find and read; `create_heartbeat` surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `create_schedule` for a release babysit, you are about to ship a status report into a void. Pattern: ```jsonc -// mcp__paseo__create_schedule arguments +// mcp__paseo__create_heartbeat arguments { "name": "vX.Y.Z release babysit heartbeat", - "every": "15m", + "cron": "*/15 * * * *", "maxRuns": 8, // covers ~2h of build + store-submission window - "target": "self", // heartbeat, NOT "new-agent" - "cwd": "/path/to/paseo", "prompt": "Heartbeat: check vX.Y.Z release. Run gh run list, eas build:list, eas workflow:runs, and eas workflow:view for the matching Release Mobile run. Report concisely. The release is not done until desktop/APK workflows are green, EAS builds are FINISHED, Android submit_android is SUCCESS, and iOS submit_ios + submit_ios_for_review are SUCCESS. Flag any ERRORED/FAILED/CANCELED/FAILURE loudly.", } ``` diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 7d20edbbe..30937e413 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -93,6 +93,7 @@ export interface CreateAgentFromMcpInput { mode?: string; background: boolean; notifyOnFinish: boolean; + detached?: boolean; callerAgentId?: string; callerContext?: { lockedCwd?: string; @@ -256,11 +257,12 @@ async function resolveMcpCreateAgent( parent: parentAgent, }); - const labels = mergeLabels( - input.callerAgentId, - input.callerContext?.childAgentDefaultLabels, - input.labels, - ); + const labels = mergeLabels({ + callerAgentId: input.callerAgentId, + detached: input.detached ?? false, + childAgentDefaultLabels: input.callerContext?.childAgentDefaultLabels, + labels: input.labels, + }); const trimmedPrompt = input.initialPrompt.trim(); return { @@ -469,15 +471,21 @@ async function createMcpWorktree( } } -function mergeLabels( - callerAgentId: string | undefined, - childAgentDefaultLabels: Record | undefined, - labels: Record | undefined, -): Record | undefined { +function mergeLabels(params: { + callerAgentId: string | undefined; + detached: boolean; + childAgentDefaultLabels: Record | undefined; + labels: Record | undefined; +}): Record | undefined { const mergedLabels = { - ...(callerAgentId ? { [PARENT_AGENT_ID_LABEL]: callerAgentId } : {}), - ...childAgentDefaultLabels, - ...labels, + ...(!params.detached && params.callerAgentId + ? { [PARENT_AGENT_ID_LABEL]: params.callerAgentId } + : {}), + ...params.childAgentDefaultLabels, + ...params.labels, }; + if (params.detached) { + delete mergedLabels[PARENT_AGENT_ID_LABEL]; + } return Object.keys(mergedLabels).length > 0 ? mergedLabels : undefined; } diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 7c248981d..9313d5730 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -166,7 +166,7 @@ async function createChildAgent(args?: Partial): Promise { } }); + test("create_agent with detached true omits the parent agent label", async () => { + let agentId: string | null = null; + try { + agentId = await createChildAgent({ detached: true }); + const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId); + expect(snapshot?.labels?.[PARENT_AGENT_ID_LABEL]).toBeUndefined(); + } finally { + await archiveAgentIfPresent(agentId); + } + }); + test("agentManager.createAgent injects paseo MCP using the daemon listen target", async () => { let agentId: string | null = null; try { @@ -565,7 +576,7 @@ describe("Suite C: Schedule Tools", () => { try { const created = await callToolStructured(topLevelClient, "create_schedule", { prompt: "say hello", - every: "5m", + cron: "*/5 * * * *", name: "Parity schedule list", provider: "claude", }); @@ -591,7 +602,7 @@ describe("Suite C: Schedule Tools", () => { try { const created = await callToolStructured(topLevelClient, "create_schedule", { prompt: "say hello", - every: "5m", + cron: "*/5 * * * *", name: "Parity provider schedule", provider: "codex/gpt-5.4", }); @@ -613,7 +624,7 @@ describe("Suite C: Schedule Tools", () => { try { const created = await callToolStructured(topLevelClient, "create_schedule", { prompt: "say hello", - every: "5m", + cron: "*/5 * * * *", name: "Parity inspect schedule", provider: "claude", }); @@ -638,7 +649,7 @@ describe("Suite C: Schedule Tools", () => { try { const created = await callToolStructured(topLevelClient, "create_schedule", { prompt: "say hello", - every: "5m", + cron: "*/5 * * * *", name: "Parity pause schedule", provider: "claude", }); @@ -665,7 +676,7 @@ describe("Suite C: Schedule Tools", () => { try { const created = await callToolStructured(topLevelClient, "create_schedule", { prompt: "say hello", - every: "5m", + cron: "*/5 * * * *", name: "Parity delete schedule", provider: "claude", }); @@ -682,14 +693,13 @@ describe("Suite C: Schedule Tools", () => { } }); - test("create_schedule target self with callerAgentId", async () => { + test("create_heartbeat targets the scoped agent", async () => { let scheduleId: string | null = null; try { - const created = await callToolStructured(agentScopedClient, "create_schedule", { + const created = await callToolStructured(agentScopedClient, "create_heartbeat", { prompt: "say hello", - every: "5m", - name: "Parity self schedule", - target: "self", + cron: "*/5 * * * *", + name: "Parity heartbeat", }); scheduleId = str(created.id); expect(created.target).toMatchObject({ @@ -706,7 +716,7 @@ describe("Suite C: Schedule Tools", () => { try { const created = await callToolStructured(agentScopedClient, "create_schedule", { prompt: "say hello", - every: "5m", + cron: "*/5 * * * *", provider: "codex/gpt-5.4", }); scheduleId = str(created.id); @@ -722,16 +732,15 @@ describe("Suite C: Schedule Tools", () => { } }); - test("create_schedule target self without callerAgentId throws", async () => { + test("create_heartbeat without callerAgentId throws", async () => { await expectToolError( topLevelClient, - "create_schedule", + "create_heartbeat", { prompt: "say hello", - every: "5m", - target: "self", + cron: "*/5 * * * *", }, - /requires a caller agent/i, + /requires an agent-scoped session/i, ); }); }); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 9b99fa07c..57c000a4d 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1692,6 +1692,141 @@ describe("create_agent MCP tool", () => { await rm(baseDir, { recursive: true, force: true }); }); + it("rejects background from caller agents and defaults notify-on-finish on", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.getAgent.mockReturnValue({ + id: "parent-agent", + cwd: existingCwd, + provider: "codex", + currentModeId: "full-access", + } as ManagedAgent); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + callerAgentId: "parent-agent", + logger, + }); + + const tool = registeredTool(server, "create_agent"); + await expect( + tool.handler({ + title: "Child", + provider: "codex/gpt-5.4", + initialPrompt: "Do work", + background: false, + }), + ).rejects.toThrow(/Unrecognized key/); + + const parsed = await tool.inputSchema.safeParseAsync({ + title: "Child", + provider: "codex/gpt-5.4", + initialPrompt: "Do work", + }); + expect(parsed.success).toBe(true); + if (!parsed.success) { + throw new Error("Expected caller create_agent input to parse"); + } + expect(parsed.data).toMatchObject({ + detached: false, + notifyOnFinish: true, + }); + }); + + it("returns notify-on-finish guidance for caller-created agents", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const parentAgent = { + id: "parent-agent", + cwd: existingCwd, + provider: "codex", + currentModeId: "full-access", + } as ManagedAgent; + const childAgent = { + id: "child-agent", + cwd: existingCwd, + lifecycle: "idle", + currentModeId: null, + availableModes: [], + config: { title: "Child" }, + } as ManagedAgent; + spies.agentManager.getAgent.mockImplementation((agentId: string) => { + if (agentId === "parent-agent") return parentAgent; + if (agentId === "child-agent") return childAgent; + return null; + }); + spies.agentManager.createAgent.mockResolvedValue(childAgent); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + callerAgentId: "parent-agent", + logger, + }); + + const tool = registeredTool(server, "create_agent"); + const response = await tool.handler({ + title: "Child", + provider: "codex/gpt-5.4", + initialPrompt: "Do work", + }); + + expect(response.structuredContent.guidance).toBe( + "You will get notified when the created agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives.", + ); + }); + + it("creates detached caller agents without a parent label", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.getAgent.mockReturnValue({ + id: "parent-agent", + cwd: existingCwd, + provider: "codex", + currentModeId: "full-access", + } as ManagedAgent); + spies.agentManager.createAgent.mockResolvedValue({ + id: "detached-agent", + cwd: existingCwd, + lifecycle: "idle", + currentModeId: null, + availableModes: [], + config: { title: "Detached" }, + } as ManagedAgent); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + callerAgentId: "parent-agent", + logger, + }); + + const tool = registeredTool(server, "create_agent"); + await tool.handler({ + title: "Detached", + provider: "codex/gpt-5.4", + initialPrompt: "Take over", + detached: true, + labels: { + [PARENT_AGENT_ID_LABEL]: "spoofed-parent", + source: "handoff", + }, + }); + + expect(spies.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: existingCwd, + }), + undefined, + { + labels: { + source: "handoff", + }, + }, + ); + }); + it("accepts provider features from caller agents and passes them through createAgent", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ @@ -1721,7 +1856,6 @@ describe("create_agent MCP tool", () => { title: "Child", provider: "codex/gpt-5.4", initialPrompt: "Do work", - background: true, settings: { features: { fast_mode: true } }, }; @@ -2139,7 +2273,7 @@ describe("update_agent MCP tool", () => { describe("create_schedule MCP tool", () => { const logger = createTestLogger(); - it("requires provider for new-agent schedules", async () => { + it("requires provider for schedules", async () => { const { agentManager, agentStorage } = createTestDeps(); const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input)); const server = await createAgentMcpServer({ @@ -2154,7 +2288,7 @@ describe("create_schedule MCP tool", () => { await expect( tool.handler({ prompt: "say hello", - every: "5m", + cron: "*/5 * * * *", name: "Default schedule", }), ).rejects.toThrow("provider is required when target is new-agent"); @@ -2175,12 +2309,12 @@ describe("create_schedule MCP tool", () => { await tool.handler({ prompt: "say hello", - every: "5m", + cron: "*/5 * * * *", provider: "codex", }); await tool.handler({ prompt: "say hello again", - every: "10m", + cron: "*/10 * * * *", provider: "codex/gpt-5.4", }); @@ -2239,8 +2373,7 @@ describe("create_schedule MCP tool", () => { const response = await tool.handler({ prompt: "say hello", - every: "5m", - target: "new-agent", + cron: "*/5 * * * *", provider: "opencode/openai/gpt-5.5", }); @@ -2251,83 +2384,6 @@ describe("create_schedule MCP tool", () => { expectOutputSchemaAccepts(tool, response.structuredContent); }); - it("accepts a blank cron field when every is provided", async () => { - const { agentManager, agentStorage } = createTestDeps(); - const create = vi.fn(async (scheduleInput: CreateScheduleInput) => - createStoredSchedule(scheduleInput), - ); - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { create } as unknown as ScheduleService, - logger, - }); - const tool = registeredTool(server, "create_schedule"); - - await invokeToolWithParsedInput(tool, { - prompt: "say hello", - every: "10m", - cron: "", - provider: "codex", - }); - - expect(create).toHaveBeenCalledWith( - expect.objectContaining({ - cadence: { type: "every", everyMs: 600000 }, - }), - ); - }); - - it.each([ - { - label: "whitespace cron field", - input: { prompt: "say hello", every: "10m", cron: " ", provider: "codex" }, - cadence: { type: "every", everyMs: 600000 }, - }, - { - label: "blank every field for cron cadence", - input: { - prompt: "say hello", - every: "", - cron: "*/10 * * * *", - provider: "codex", - }, - cadence: { type: "cron", expression: "*/10 * * * *" }, - }, - { - label: "whitespace every field for cron cadence", - input: { - prompt: "say hello", - every: " ", - cron: "*/10 * * * *", - provider: "codex", - }, - cadence: { type: "cron", expression: "*/10 * * * *" }, - }, - ])("normalizes create_schedule blank cadence input for $label", async ({ input, cadence }) => { - const { agentManager, agentStorage } = createTestDeps(); - const create = vi.fn(async (scheduleInput: CreateScheduleInput) => - createStoredSchedule(scheduleInput), - ); - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { create } as unknown as ScheduleService, - logger, - }); - const tool = registeredTool(server, "create_schedule"); - - await invokeToolWithParsedInput(tool, input); - - expect(create).toHaveBeenCalledWith( - expect.objectContaining({ - cadence, - }), - ); - }); - it("passes timezone through cron create_schedule input", async () => { const { agentManager, agentStorage } = createTestDeps(); const create = vi.fn(async (scheduleInput: CreateScheduleInput) => @@ -2360,7 +2416,7 @@ describe("create_schedule MCP tool", () => { ); }); - it("still rejects both real every and cron inputs", async () => { + it("rejects removed create_schedule every input", async () => { const { agentManager, agentStorage } = createTestDeps(); const create = vi.fn(); const server = await createAgentMcpServer({ @@ -2372,19 +2428,17 @@ describe("create_schedule MCP tool", () => { }); const tool = registeredTool(server, "create_schedule"); - await expect( - invokeToolWithParsedInput(tool, { - prompt: "say hello", - every: "10m", - cron: "*/10 * * * *", - provider: "codex", - }), - ).rejects.toThrow("Specify exactly one of every or cron"); + const parsed = await tool.inputSchema.safeParseAsync({ + prompt: "say hello", + every: "10m", + provider: "codex", + }); + expect(parsed.success).toBe(false); expect(create).not.toHaveBeenCalled(); }); - it("rejects create_schedule timezone without cron", async () => { + it("rejects create_schedule without cron", async () => { const { agentManager, agentStorage } = createTestDeps(); const create = vi.fn(); const server = await createAgentMcpServer({ @@ -2397,13 +2451,11 @@ describe("create_schedule MCP tool", () => { const tool = registeredTool(server, "create_schedule"); await expect( - invokeToolWithParsedInput(tool, { + tool.handler({ prompt: "say hello", - every: "10m", - timezone: "America/New_York", provider: "codex", }), - ).rejects.toThrow("timezone can only be used with cron"); + ).rejects.toThrow(/cron/); expect(create).not.toHaveBeenCalled(); }); @@ -2431,17 +2483,55 @@ describe("create_schedule MCP tool", () => { expect(create).not.toHaveBeenCalled(); }); +}); - it.each([ - { - label: "missing both cadence fields", - input: { prompt: "say hello", provider: "codex" }, - }, - { - label: "blank cadence fields", - input: { prompt: "say hello", every: " ", cron: "", provider: "codex" }, - }, - ])("still rejects create_schedule when $label", async ({ input }) => { +describe("create_heartbeat MCP tool", () => { + const logger = createTestLogger(); + + it("creates a self-targeted cron heartbeat", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.getAgent.mockReturnValue({ + id: "parent-agent", + provider: "codex", + cwd: REPO_CWD, + lifecycle: "idle", + currentModeId: "build", + availableModes: [], + config: { title: "Parent agent" }, + } as ManagedAgent); + const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input)); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + scheduleService: { create } as unknown as ScheduleService, + callerAgentId: "parent-agent", + logger, + }); + const tool = registeredTool(server, "create_heartbeat"); + + await invokeToolWithParsedInput(tool, { + prompt: "check status", + cron: "*/15 * * * *", + timezone: "America/New_York", + name: "status heartbeat", + }); + + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "check status", + cadence: { + type: "cron", + expression: "*/15 * * * *", + timezone: "America/New_York", + }, + target: { type: "agent", agentId: "parent-agent" }, + name: "status heartbeat", + }), + ); + }); + + it("requires an agent-scoped session", async () => { const { agentManager, agentStorage } = createTestDeps(); const create = vi.fn(); const server = await createAgentMcpServer({ @@ -2451,11 +2541,14 @@ describe("create_schedule MCP tool", () => { scheduleService: { create } as unknown as ScheduleService, logger, }); - const tool = registeredTool(server, "create_schedule"); + const tool = registeredTool(server, "create_heartbeat"); - await expect(invokeToolWithParsedInput(tool, input)).rejects.toThrow( - "Specify exactly one of every or cron", - ); + await expect( + tool.handler({ + prompt: "check status", + cron: "*/15 * * * *", + }), + ).rejects.toThrow("create_heartbeat requires an agent-scoped session"); expect(create).not.toHaveBeenCalled(); }); @@ -3131,7 +3224,7 @@ describe("speak MCP tool", () => { }); const tool = registeredTool(server, "speak"); await expect(tool.handler({ text: "Hello." })).rejects.toThrow( - "No speak handler registered for caller agent", + "No speak handler registered for your session", ); }); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 4d4e1ccaa..0604d62d9 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -514,6 +514,28 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom registerRawTool(name, relaxMcpToolOutputSchema(config), (async (args: never, extra: never) => addModelVisibleStructuredContent(await handler(args, extra))) as typeof handler); + const buildCronScheduleCadence = (input: { + cron: string | undefined; + timezone?: string; + }): ScheduleCadence => { + const expression = input.cron?.trim() ?? ""; + if (!expression) { + throw new Error("cron is required"); + } + const timezone = normalizeScheduleTimeZoneArg(input.timezone); + return { + type: "cron", + expression, + ...(timezone !== undefined ? { timezone } : {}), + }; + }; + + const buildScheduleExpiry = (expiresIn: string | undefined): string | undefined => { + return expiresIn === undefined + ? undefined + : new Date(Date.now() + parseDurationString(expiresIn)).toISOString(); + }; + const resolveCallerAgent = () => { if (!callerAgentId) { return null; @@ -541,7 +563,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom if (opts?.required) { throw new Error("cwd is required"); } - throw new Error("cwd is required when no caller agent is available"); + throw new Error("cwd is required outside an agent-scoped session"); } return expandUserPath(trimmedCwd); @@ -699,7 +721,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom cwd: z .string() .optional() - .describe("Optional working directory. Defaults to the caller agent working directory."), + .describe("Optional working directory. Defaults to your current working directory."), title: z .string() .trim() @@ -718,19 +740,19 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom .trim() .min(1, "initialPrompt is required") .describe("Required first task to run immediately after creation."), - background: z + detached: z .boolean() .optional() .default(false) .describe( - "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.", + "If true, the created agent stands on its own: it does not appear in your subagent track and is not archived with you.", ), notifyOnFinish: z .boolean() .optional() - .default(false) + .default(true) .describe( - "Send a notification prompt to the caller agent when this agent finishes, errors, or needs permission. Requires a caller agent context.", + "Get notified when the created agent finishes, errors, or needs permission. Set false only for truly fire-and-forget agents.", ), }; @@ -787,7 +809,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom .optional() .default(false) .describe( - "Send a notification prompt to the caller agent when this agent finishes, errors, or needs permission. Requires a caller agent context.", + "Agent-scoped only: get notified when the created agent finishes, errors, or needs permission.", ), }; @@ -806,6 +828,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom "Draft provider settings used to compute available features.", ), }; + type AgentToAgentCreateAgentArgs = z.infer; type TopLevelCreateAgentArgs = z.infer; if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) { @@ -832,7 +855,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom } const handler = resolveSpeakHandler?.(callerAgentId) ?? null; if (!handler) { - throw new Error(`No speak handler registered for caller agent '${callerAgentId}'`); + throw new Error(`No speak handler registered for your session '${callerAgentId}'`); } await handler({ text: args.text, @@ -867,11 +890,29 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom availableModes: z.array(ProviderModeSchema), lastMessage: z.string().nullable().optional(), permission: AgentPermissionRequestPayloadSchema.nullable().optional(), + guidance: z.string().optional(), }, }, async (args: unknown) => { - const { parsedArgs, worktree } = resolveCreateAgentToolArgs(args); - const { snapshot, background, initialPromptStarted } = await createAgentCommand( + const resolvedArgs = resolveCreateAgentToolArgs(args); + const { parsedArgs, worktree } = resolvedArgs; + let requestedBackground: boolean; + let notifyOnFinish: boolean; + let detached: boolean; + if (resolvedArgs.kind === "agent-scoped") { + requestedBackground = true; + notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish; + detached = resolvedArgs.parsedArgs.detached; + } else { + requestedBackground = resolvedArgs.parsedArgs.background; + notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false; + detached = false; + } + const { + snapshot, + background: createdInBackground, + initialPromptStarted, + } = await createAgentCommand( { agentManager, agentStorage, @@ -892,8 +933,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom features: parsedArgs.settings?.features, labels: parsedArgs.labels, mode: parsedArgs.settings?.modeId, - background: parsedArgs.background ?? false, - notifyOnFinish: parsedArgs.notifyOnFinish ?? false, + background: requestedBackground, + notifyOnFinish, + detached, callerAgentId, callerContext, worktree, @@ -901,7 +943,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom ); try { - if (!background && initialPromptStarted) { + if (!createdInBackground && initialPromptStarted) { const result = await waitForAgentWithTimeout(agentManager, snapshot.id, { waitForActive: true, }); @@ -930,8 +972,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom throw error; } - // Return immediately if background=true + // Return immediately for async creation. const currentSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot; + const guidance = + callerAgentId && notifyOnFinish && initialPromptStarted + ? "You will get notified when the created agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives." + : undefined; const response = { content: [], structuredContent: ensureValidJson({ @@ -943,26 +989,36 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom availableModes: currentSnapshot.availableModes, lastMessage: null, permission: null, + ...(guidance ? { guidance } : {}), }), }; return response; }, ); - function resolveCreateAgentToolArgs(args: unknown): { - parsedArgs: - | z.infer - | z.infer; - worktree: ReturnType; - } { + type ResolvedCreateAgentToolArgs = + | { + kind: "agent-scoped"; + parsedArgs: AgentToAgentCreateAgentArgs; + worktree: undefined; + } + | { + kind: "top-level"; + parsedArgs: TopLevelCreateAgentArgs; + worktree: ReturnType; + }; + + function resolveCreateAgentToolArgs(args: unknown): ResolvedCreateAgentToolArgs { if (callerAgentId) { return { + kind: "agent-scoped", parsedArgs: agentToAgentCreateAgentArgsSchema.parse(args), worktree: undefined, }; } const parsedArgs = topLevelCreateAgentArgsSchema.parse(args); return { + kind: "top-level", parsedArgs, worktree: resolveTopLevelCreateAgentWorktree(parsedArgs), }; @@ -1088,7 +1144,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom .optional() .default(false) .describe( - "Send a notification prompt to the caller agent when this agent finishes, errors, or needs permission.", + "Agent-scoped only: get notified when this run finishes, errors, or needs permission.", ), }, outputSchema: { @@ -1402,7 +1458,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom cwd: z .string() .optional() - .describe("Optional working directory. Defaults to the caller agent cwd."), + .describe("Optional working directory. Defaults to your current working directory."), all: z.boolean().optional().describe("List terminals across all working directories."), }, outputSchema: { @@ -1450,7 +1506,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom cwd: z .string() .optional() - .describe("Optional working directory. Defaults to the caller agent cwd."), + .describe("Optional working directory. Defaults to your current working directory."), name: z.string().optional().describe("Optional terminal name."), }, outputSchema: TerminalSummarySchema.shape, @@ -1591,22 +1647,18 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom "create_schedule", { title: "Create schedule", - description: "Create a recurring schedule that runs on an agent or a new agent.", + description: "Create a recurring schedule that starts a new agent on a cron cadence.", inputSchema: { prompt: z.string().trim().min(1, "prompt is required"), - every: z.string().optional(), - cron: z.string().optional(), + cron: z.string().trim().min(1, "cron is required"), timezone: z .string() .trim() .min(1) .optional() - .describe( - "IANA time zone for cron cadence; requires cron. For example: America/New_York.", - ), + .describe("IANA time zone for the cron cadence. For example: America/New_York."), name: z.string().optional(), - target: z.enum(["self", "new-agent"]).optional(), - provider: AgentProviderEnum.optional().describe( + provider: AgentProviderEnum.describe( "Provider, or provider/model (for example: codex or codex/gpt-5.4).", ), cwd: z.string().optional(), @@ -1615,70 +1667,71 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, outputSchema: ScheduleSummarySchema.shape, }, - async ({ prompt, every, cron, timezone, name, target, provider, cwd, maxRuns, expiresIn }) => { + async ({ prompt, cron, timezone, name, provider, cwd, maxRuns, expiresIn }) => { if (!scheduleService) { throw new Error("Schedule service is not configured"); } - const normalizedEvery = normalizeScheduleCadenceArg(every); - const normalizedCron = normalizeScheduleCadenceArg(cron); - const normalizedTimeZone = normalizeScheduleTimeZoneArg(timezone); - const cadenceCount = - Number(normalizedEvery !== undefined) + Number(normalizedCron !== undefined); - if (cadenceCount !== 1) { - throw new Error("Specify exactly one of every or cron"); - } - if (normalizedTimeZone !== undefined && normalizedCron === undefined) { - throw new Error("timezone can only be used with cron"); - } - - const scheduleTarget = - target === "self" - ? (() => { - const callerAgent = resolveCallerAgent(); - if (!callerAgentId || !callerAgent) { - throw new Error("target=self requires a caller agent"); - } - const trimmedCwd = cwd?.trim(); - if (trimmedCwd && expandUserPath(trimmedCwd) !== callerAgent.cwd) { - throw new Error("cwd can only differ from the caller agent when target=new-agent"); - } - if (provider !== undefined) { - const resolved = resolveScheduleProviderAndModel({ - provider, - defaultProvider: callerAgent.provider, - }); - if ( - resolved.provider !== callerAgent.provider || - (resolved.model !== undefined && resolved.model !== callerAgent.config.model) - ) { - throw new Error( - "provider can only differ from the caller agent when target=new-agent", - ); - } - } - return { type: "agent" as const, agentId: callerAgentId }; - })() - : (() => { - return resolveNewAgentScheduleTarget({ provider, cwd }); - })(); - + const expiresAt = buildScheduleExpiry(expiresIn); const schedule = await scheduleService.create({ prompt: prompt.trim(), - cadence: - normalizedEvery !== undefined - ? { type: "every" as const, everyMs: parseDurationString(normalizedEvery) } - : { - type: "cron" as const, - expression: normalizedCron!, - ...(normalizedTimeZone !== undefined ? { timezone: normalizedTimeZone } : {}), - }, - target: scheduleTarget, + cadence: buildCronScheduleCadence({ + cron, + ...(timezone !== undefined ? { timezone } : {}), + }), + target: resolveNewAgentScheduleTarget({ provider, cwd }), ...(name?.trim() ? { name: name.trim() } : {}), ...(maxRuns === undefined ? {} : { maxRuns }), - ...(expiresIn === undefined - ? {} - : { expiresAt: new Date(Date.now() + parseDurationString(expiresIn)).toISOString() }), + ...(expiresAt === undefined ? {} : { expiresAt }), + }); + + return { + content: [], + structuredContent: ensureValidJson(toScheduleSummary(schedule)), + }; + }, + ); + + registerTool( + "create_heartbeat", + { + title: "Create heartbeat", + description: "Create a recurring heartbeat that sends you a prompt on a cron cadence.", + inputSchema: { + prompt: z.string().trim().min(1, "prompt is required"), + cron: z.string().trim().min(1, "cron is required"), + timezone: z + .string() + .trim() + .min(1) + .optional() + .describe("IANA time zone for the cron cadence. For example: America/New_York."), + name: z.string().optional(), + maxRuns: z.number().int().positive().optional(), + expiresIn: z.string().optional(), + }, + outputSchema: ScheduleSummarySchema.shape, + }, + async ({ prompt, cron, timezone, name, maxRuns, expiresIn }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + if (!callerAgentId) { + throw new Error("create_heartbeat requires an agent-scoped session"); + } + resolveCallerAgent(); + + const expiresAt = buildScheduleExpiry(expiresIn); + const schedule = await scheduleService.create({ + prompt: prompt.trim(), + cadence: buildCronScheduleCadence({ + cron, + ...(timezone !== undefined ? { timezone } : {}), + }), + target: { type: "agent", agentId: callerAgentId }, + ...(name?.trim() ? { name: name.trim() } : {}), + ...(maxRuns === undefined ? {} : { maxRuns }), + ...(expiresAt === undefined ? {} : { expiresAt }), }); return { @@ -2027,7 +2080,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom cwd: z .string() .optional() - .describe("Optional repository cwd. Defaults to the caller agent cwd."), + .describe("Optional repository cwd. Defaults to your current working directory."), }, outputSchema: { worktrees: z.array(WorktreeSummarySchema), @@ -2131,7 +2184,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom cwd: z .string() .optional() - .describe("Optional repository cwd. Defaults to the caller agent cwd."), + .describe("Optional repository cwd. Defaults to your current working directory."), worktreePath: z.string().optional(), worktreeSlug: z.string().optional(), }, diff --git a/skills/paseo-advisor/SKILL.md b/skills/paseo-advisor/SKILL.md index 9cc712aab..cca43f6ee 100644 --- a/skills/paseo-advisor/SKILL.md +++ b/skills/paseo-advisor/SKILL.md @@ -13,7 +13,7 @@ Single agent. Reads the situation you're in. Gives a judgment. You decide what t ## Prerequisites -Read the **paseo** skill — provider for the advisor comes from orchestration preferences unless the user names one. +Read the **paseo** skill. Before choosing a provider, read `~/.paseo/orchestration-preferences.json` unless the user explicitly named a provider in this request. Do not create the advisor until you have read it. ## Picking the advisor diff --git a/skills/paseo-committee/SKILL.md b/skills/paseo-committee/SKILL.md index 6e6fb88de..274cf4e8f 100644 --- a/skills/paseo-committee/SKILL.md +++ b/skills/paseo-committee/SKILL.md @@ -14,14 +14,16 @@ The purpose is to step back, not double down. The committee may propose a comple ## Prerequisites -Read the **paseo** skill. Contrast is the point of a committee, so pick across providers deliberately rather than using whatever the default category would resolve to. +Read the **paseo** skill. Before choosing committee members, read `~/.paseo/orchestration-preferences.json` unless the user explicitly named providers in this request. Do not create committee agents until you have read it. + +Contrast is the point of a committee, so pick across providers deliberately using the configured preferences rather than hardcoded defaults. ## Composition -Two members with different reasoning styles: +Two members with different reasoning styles, selected from orchestration preferences: -- **Claude Opus** with extended thinking on -- **Codex GPT-5.4** with thinking on +- one planning/research-strength provider +- one contrasting high-reasoning provider Override only when the user explicitly asks for different members. diff --git a/skills/paseo-epic/SKILL.md b/skills/paseo-epic/SKILL.md index 07f80b305..951b37aa1 100644 --- a/skills/paseo-epic/SKILL.md +++ b/skills/paseo-epic/SKILL.md @@ -17,6 +17,8 @@ This usually runs for hours, often overnight. By the time agents are working, yo This is a Paseo skill. Load the **paseo** skill first — it carries the surface (worktrees, agents, waiting, scheduling, preferences). Every agent you spawn reads it too. +Before choosing any provider, read `~/.paseo/orchestration-preferences.json`. Do not create planner, reviewer, researcher, implementer, or auditor agents until you have read it. + **Do not use your own subagents.** All agents in this skill are Paseo agents, spawned through the Paseo MCP. Your harness's native subagent stack is not in play here. The role and phase-type vocabulary lives in the roles reference shipped with this skill (`references/roles.md`). diff --git a/skills/paseo-handoff/SKILL.md b/skills/paseo-handoff/SKILL.md index 243062f56..034201ea6 100644 --- a/skills/paseo-handoff/SKILL.md +++ b/skills/paseo-handoff/SKILL.md @@ -12,7 +12,7 @@ Transfer the current task — context, decisions, failed attempts, constraints ## Prerequisites -Read the **paseo** skill — provider for the receiving agent comes from orchestration preferences unless the user names one. +Read the **paseo** skill. Before choosing a provider, read `~/.paseo/orchestration-preferences.json` unless the user explicitly named a provider in this request. Do not create the receiving agent until you have read it. ## Parsing arguments @@ -29,7 +29,7 @@ The receiving agent has zero context. Include: [Imperative description.] ## Context -[Why this task exists, background needed.] +[Why this task exists, required context.] ## Relevant files - `path/to/file.ts` — [what it is and why it matters] @@ -54,6 +54,8 @@ The receiving agent has zero context. Include: ## Launch -Create the agent via Paseo with a `[Handoff] ` title, the briefing as initial prompt, and cwd set to the worktree path if `--worktree`. +Create the agent via Paseo with a `[Handoff] ` title, the briefing as initial prompt, `detached: true`, and cwd set to the worktree path if `--worktree`. Leave `notifyOnFinish` omitted unless the user explicitly wants no callback. + +Handoff agents are siblings/root agents, not your subagents. They must survive you being archived and must not appear in your subagent track. Don't wait by default — the user decides whether to follow along or move on. Tell them the agent ID and how to follow along (the paseo skill explains). diff --git a/skills/paseo-loop/SKILL.md b/skills/paseo-loop/SKILL.md index 6b884c6f1..966983e8d 100644 --- a/skills/paseo-loop/SKILL.md +++ b/skills/paseo-loop/SKILL.md @@ -12,7 +12,7 @@ A loop is a worker/verifier cycle: launch a worker → check verification → re ## Prerequisites -Read the **paseo** skill for orchestration preferences — worker and verifier providers come from preferences unless the user names them. +Read the **paseo** skill. Before choosing worker or verifier providers, read `~/.paseo/orchestration-preferences.json` unless the user explicitly named providers in this request. Do not start the loop until you have read it. Loops are a CLI primitive: `paseo loop run`. Manage with `paseo loop ls`, `paseo loop inspect `, `paseo loop logs `, `paseo loop stop `. diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index c40be9f72..84902b957 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -20,13 +20,21 @@ Returns `{ branchName, worktreePath }`. Pass `cwd` to target a specific repo. ## Agents -**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `cwd` (often a `worktreePath`), `background` (default `false` — blocks until completion or permission), `notifyOnFinish`, `settings`. Returns `{ agentId, … }`. +**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `cwd` (often a `worktreePath`), `notifyOnFinish`, `settings`, `detached`. Returns `{ agentId, … }`. 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. Compose: call `create_worktree` first, then `create_agent` with `cwd` set to the returned `worktreePath`. -**`send_agent_prompt`** — `{ agentId, prompt }`. Blocks by default; pass `background: true` to fire-and-forget. +### Agent relationships + +Agents you create default to **your subagents**: omit `detached` or pass `detached: false`. Use this for advisors, committee members, planners, implementers, auditors, loop workers, and any agent whose lifetime belongs to your task. Subagents appear under you and are archived with you. + +Pass `detached: true` only when the agent you create should stand on its own, not help you finish your task. Use this for handoffs and fire-and-forget delegations the user may continue after you are archived. Detached agents do not appear in your subagent track and are not archived with you. + +For subagents, leave `notifyOnFinish` omitted or set it to `true`. You will get notified when the created agent finishes, errors, or needs permission. Set `notifyOnFinish: false` only when the created agent is truly fire-and-forget and you do not need to follow up. + +**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent. **`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`. @@ -44,9 +52,11 @@ Compose: call `create_worktree` first, then `create_agent` with `cwd` set to the Only set feature IDs returned by `inspect_provider`. For Codex fast mode, look for `fast_mode` and pass `settings: { features: { "fast_mode": true } }` to `create_agent` or `update_agent`. -## Heartbeats +## Schedules and heartbeats -**`create_schedule`** — required: `prompt`. Pick one of `cron` or `every` (`"5m"`, `"1h"`). Optional: `name`, `target` (`self` | `new-agent`), `provider`, `maxRuns`, `expiresIn`. Use for periodic checks on long-running work or recurring maintenance. +**`create_schedule`** — starts a new agent on a cron cadence. Required: `prompt`, `cron`, `provider`. Optional: `timezone`, `name`, `cwd`, `maxRuns`, `expiresIn`. Use when the recurring work should live in fresh agents. + +**`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. ## Models @@ -54,7 +64,7 @@ Only set feature IDs returned by `inspect_provider`. For Codex fast mode, look f ## Orchestration preferences -User-specific configuration at `~/.paseo/orchestration-preferences.json`. **Any paseo skill that picks an agent reads this file.** Never hardcode a provider string in another skill — resolve through this file. +User-specific configuration at `~/.paseo/orchestration-preferences.json`. **Before any Paseo skill chooses a provider or creates an agent, it must read this file.** Reading means an actual file read, not relying on these examples or defaults. Never hardcode a provider string in another skill — resolve through this file. Two parts: @@ -84,7 +94,7 @@ If the file is missing, use sensible defaults and tell the user once. Agents take time — 10–30+ minutes is routine. Favor asynchronous workflows. -For every `create_agent` or `send_agent_prompt`, pass `background: true` and `notifyOnFinish: true`. Paseo delivers a notification to your conversation when the agent finishes, errors, or needs permission. **You must not call `wait_for_agent` on a notify-on-finish agent.** Move on to other work. The notification arrives on its own. +For `create_agent`, leave `notifyOnFinish` omitted or set it to `true` unless the created agent is truly fire-and-forget. You will get notified when the created agent finishes, errors, or needs permission. **You must not call `wait_for_agent` on a notify-on-finish agent.** Move on to other work. The notification arrives on its own. Don't poll `list_agents` or `get_agent_status` to "check on" a running agent. The notification will tell you. @@ -97,7 +107,7 @@ paseo run --provider codex/gpt-5.4 --mode full-access --worktree feat/x " "" paseo ls paseo worktree ls -paseo schedule create --every 5m "ping main build" +paseo schedule create --cron "*/15 * * * *" "ping main build" ``` Discover with `paseo --help` and `paseo --help`.