Add detached agents and heartbeat scheduling (#1266)

This commit is contained in:
Mohamed Boudra
2026-06-01 14:07:31 +08:00
committed by GitHub
parent 89ec358d00
commit 7fb3dda20c
13 changed files with 443 additions and 271 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.",
}
```

View File

@@ -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<string, string> | undefined,
labels: Record<string, string> | undefined,
): Record<string, string> | undefined {
function mergeLabels(params: {
callerAgentId: string | undefined;
detached: boolean;
childAgentDefaultLabels: Record<string, string> | undefined;
labels: Record<string, string> | undefined;
}): Record<string, string> | 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;
}

View File

@@ -166,7 +166,7 @@ async function createChildAgent(args?: Partial<StructuredContent>): Promise<stri
title: "Parity child",
provider: "claude/claude-test-model",
initialPrompt: "say done and stop",
background: true,
notifyOnFinish: false,
...args,
});
return str(payload.agentId);
@@ -286,6 +286,17 @@ describe("Suite A: Core Fixes", () => {
}
});
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,
);
});
});

View File

@@ -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",
);
});

View File

@@ -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<typeof agentToAgentCreateAgentArgsSchema>;
type TopLevelCreateAgentArgs = z.infer<typeof topLevelCreateAgentArgsSchema>;
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<typeof agentToAgentCreateAgentArgsSchema>
| z.infer<typeof topLevelCreateAgentArgsSchema>;
worktree: ReturnType<typeof resolveTopLevelCreateAgentWorktree>;
} {
type ResolvedCreateAgentToolArgs =
| {
kind: "agent-scoped";
parsedArgs: AgentToAgentCreateAgentArgs;
worktree: undefined;
}
| {
kind: "top-level";
parsedArgs: TopLevelCreateAgentArgs;
worktree: ReturnType<typeof resolveTopLevelCreateAgentWorktree>;
};
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(),
},

View File

@@ -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

View File

@@ -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.

View File

@@ -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`).

View File

@@ -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] <task>` title, the briefing as initial prompt, and cwd set to the worktree path if `--worktree`.
Create the agent via Paseo with a `[Handoff] <task>` 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).

View File

@@ -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 <id>`, `paseo loop logs <id>`, `paseo loop stop <id>`.

View File

@@ -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 — 1030+ 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 "<prompt
paseo send <agent-id> "<follow-up>"
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 <cmd> --help`.