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

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