Merge branch 'research/mcp-list-agents-summary'

This commit is contained in:
Mohamed Boudra
2026-04-21 13:46:25 +07:00
9 changed files with 841 additions and 247 deletions
@@ -132,7 +132,7 @@ describe("agent MCP end-to-end (offline)", () => {
args: {
cwd: agentCwd,
title: "MCP e2e smoke",
provider: "claude",
provider: "claude/claude-test-model",
mode: "bypassPermissions",
initialPrompt,
background: false,
@@ -223,7 +223,7 @@ describe("agent MCP end-to-end (offline)", () => {
args: {
cwd: agentCwd,
title: "Injected MCP",
provider: "claude",
provider: "claude/claude-test-model",
mode: "bypassPermissions",
initialPrompt: "reply with done and stop",
background: true,
@@ -246,7 +246,7 @@ describe("agent MCP end-to-end (offline)", () => {
args: {
cwd: disabledAgentCwd,
title: "No injected MCP",
provider: "claude",
provider: "claude/claude-test-model",
mode: "bypassPermissions",
initialPrompt: "reply with done and stop",
background: true,
@@ -343,7 +343,7 @@ describe("agent MCP end-to-end (offline)", () => {
args: {
cwd: repoRoot,
title: "MCP worktree setup terminals",
provider: "claude",
provider: "claude/claude-test-model",
mode: "bypassPermissions",
initialPrompt: "say done and stop",
worktreeName: "mcp-worktree-setup-test",
@@ -1,4 +1,4 @@
import type { AgentSnapshotPayload } from "../messages.js";
import type { AgentListItemPayload, AgentSnapshotPayload } from "../messages.js";
import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
import type {
AgentCapabilityFlags,
@@ -31,6 +31,17 @@ function normalizeThinkingOptionId(value: string | null | undefined): string | n
return normalized.length > 0 ? normalized : null;
}
function normalizeLabels(labels: Record<string, unknown> | undefined): Record<string, string> {
if (!labels) {
return {};
}
return Object.fromEntries(
Object.entries(labels).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
}
export function resolveEffectiveThinkingOptionId(options: {
runtimeInfo?: AgentRuntimeInfo | null;
configuredThinkingOptionId?: string | null;
@@ -198,7 +209,29 @@ export function buildStoredAgentPayload(
attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ?? null,
archivedAt: record.archivedAt ?? null,
labels: record.labels,
labels: normalizeLabels(record.labels),
};
}
export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListItemPayload {
return {
id: agent.id,
shortId: agent.id.slice(0, 7),
title: agent.title,
provider: agent.provider,
model: agent.runtimeInfo?.model ?? agent.model,
thinkingOptionId: agent.thinkingOptionId,
effectiveThinkingOptionId: agent.effectiveThinkingOptionId,
status: agent.status,
cwd: agent.cwd,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
lastUserMessageAt: agent.lastUserMessageAt,
archivedAt: agent.archivedAt ?? null,
requiresAttention: agent.requiresAttention ?? false,
attentionReason: agent.attentionReason ?? null,
attentionTimestamp: agent.attentionTimestamp ?? null,
labels: agent.labels,
};
}
@@ -121,7 +121,7 @@ describe("MCP parity end-to-end", () => {
const payload = await callToolStructured(topLevelClient, "create_agent", {
cwd,
title: "Parity agent",
provider: "claude",
provider: "claude/claude-test-model",
initialPrompt: "say done and stop",
mode: "bypassPermissions",
background: true,
@@ -133,7 +133,7 @@ describe("MCP parity end-to-end", () => {
async function createChildAgent(args?: Partial<StructuredContent>): Promise<string> {
const payload = await callToolStructured(agentScopedClient, "create_agent", {
title: "Parity child",
provider: "claude",
provider: "claude/claude-test-model",
initialPrompt: "say done and stop",
background: true,
...args,
@@ -207,7 +207,7 @@ describe("MCP parity end-to-end", () => {
const parentPayload = await callToolStructured(topLevelClient, "create_agent", {
cwd: parentAgentCwd,
title: "MCP parity parent",
provider: "claude",
provider: "claude/claude-test-model",
initialPrompt: "say done and stop",
mode: "bypassPermissions",
background: true,
@@ -294,10 +294,10 @@ describe("MCP parity end-to-end", () => {
}
});
test("create_agent accepts model param", async () => {
test("create_agent accepts provider/model syntax", async () => {
let agentId: string | null = null;
try {
agentId = await createTopLevelAgent({ model: "claude-test-model" });
agentId = await createTopLevelAgent({ provider: "claude/claude-test-model" });
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
expect(snapshot?.config.model).toBe("claude-test-model");
} finally {
File diff suppressed because it is too large Load Diff
+137 -33
View File
@@ -9,10 +9,16 @@ import type { AgentProvider } from "./agent-sdk-types.js";
import type { AgentManager, WaitForAgentResult } from "./agent-manager.js";
import {
AgentPermissionRequestPayloadSchema,
AgentListItemPayloadSchema,
AgentPermissionResponseSchema,
AgentSnapshotPayloadSchema,
} from "../messages.js";
import { buildStoredAgentPayload, toAgentPayload } from "./agent-projections.js";
import type { AgentListItemPayload } from "../messages.js";
import {
buildStoredAgentPayload,
toAgentListItemPayload,
toAgentPayload,
} from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
@@ -24,7 +30,7 @@ import { deletePaseoWorktree, type WorktreeConfig } from "../../utils/worktree.j
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { captureTerminalLines } from "../../terminal/terminal.js";
import { runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
@@ -38,7 +44,7 @@ import {
AgentStatusEnum,
ProviderSummarySchema,
parseDurationString,
resolveProviderAndModel,
resolveRequiredProviderModel,
sanitizePermissionRequest,
sendPromptToAgent,
setupFinishNotification,
@@ -126,6 +132,68 @@ function mapModeAcrossProviders(
type McpToolContext = RequestHandlerExtra<ServerRequest, ServerNotification>;
function parseTimestamp(value: string | null | undefined): number {
if (!value) {
return 0;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
function resolveAgentListActivityTime(agent: AgentListItemPayload): number {
return Math.max(
parseTimestamp(agent.updatedAt),
parseTimestamp(agent.lastUserMessageAt),
parseTimestamp(agent.attentionTimestamp),
parseTimestamp(agent.archivedAt),
parseTimestamp(agent.createdAt),
);
}
function compareAgentListItems(a: AgentListItemPayload, b: AgentListItemPayload): number {
const attentionDelta =
Number(b.requiresAttention ?? false) - Number(a.requiresAttention ?? false);
if (attentionDelta !== 0) {
return attentionDelta;
}
const statusOrder = {
running: 0,
initializing: 1,
idle: 2,
error: 3,
closed: 4,
} as Record<string, number>;
const statusDelta = (statusOrder[a.status] ?? 999) - (statusOrder[b.status] ?? 999);
if (statusDelta !== 0) {
return statusDelta;
}
return resolveAgentListActivityTime(b) - resolveAgentListActivityTime(a);
}
function resolveScheduleProviderAndModel(params: {
provider?: string;
defaultProvider: AgentProvider;
}): { provider: AgentProvider; model?: string } {
const providerInput = params.provider?.trim() || params.defaultProvider;
const slashIndex = providerInput.indexOf("/");
if (slashIndex === -1) {
return { provider: providerInput as AgentProvider };
}
const provider = providerInput.slice(0, slashIndex).trim();
const model = providerInput.slice(slashIndex + 1).trim();
if (!provider || !model) {
throw new Error("provider must be <provider> or <provider>/<model>");
}
return {
provider: provider as AgentProvider,
model,
};
}
function resolveChildAgentCwd(params: {
parentCwd: string;
requestedCwd?: string;
@@ -257,7 +325,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (callerAgent) {
const hasProviderOverride = params?.provider !== undefined;
const resolvedProviderModel = hasProviderOverride
? resolveProviderAndModel({
? resolveScheduleProviderAndModel({
provider: params?.provider,
defaultProvider: callerAgent.provider,
})
@@ -307,21 +375,34 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
};
}
const resolvedProviderModel = resolveScheduleProviderAndModel({
provider: params?.provider,
defaultProvider: "claude",
});
return {
type: "new-agent" as const,
config: (() => {
const resolvedProviderModel = resolveProviderAndModel({
provider: params?.provider,
defaultProvider: "claude",
});
return {
provider: resolvedProviderModel.provider,
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
};
})(),
config: {
provider: resolvedProviderModel.provider,
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
},
};
};
const ProviderModelInputSchema = AgentProviderEnum.trim()
.refine((value) => value.includes("/"), {
message: "provider must be provider/model, for example codex/gpt-5.4",
})
.refine(
(value) => {
try {
resolveRequiredProviderModel(value);
return true;
} catch {
return false;
}
},
{ message: "provider must be provider/model, for example codex/gpt-5.4" },
);
const agentToAgentInputSchema = {
cwd: z
.string()
@@ -333,10 +414,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.min(1, "Title is required")
.max(60, "Title must be 60 characters or fewer")
.describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."),
provider: AgentProviderEnum.optional().describe(
"Optional agent implementation to spawn. Defaults to 'claude'.",
provider: ProviderModelInputSchema.describe(
"Required provider/model pair, for example codex/gpt-5.4.",
),
model: z.string().optional().describe("Model to use (e.g. claude-sonnet-4-20250514)"),
thinking: z.string().optional().describe("Thinking option ID"),
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
initialPrompt: z
@@ -370,10 +450,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.min(1, "Title is required")
.max(60, "Title must be 60 characters or fewer")
.describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."),
provider: AgentProviderEnum.optional().describe(
"Optional agent implementation to spawn. Defaults to 'claude'.",
provider: ProviderModelInputSchema.describe(
"Required provider/model pair, for example codex/gpt-5.4.",
),
model: z.string().optional().describe("Model to use (e.g. claude-sonnet-4-20250514)"),
thinking: z.string().optional().describe("Thinking option ID"),
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
initialPrompt: z
@@ -421,8 +500,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
};
const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema;
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema);
const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema);
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict();
const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema).strict();
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
server.registerTool(
@@ -472,7 +551,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
{
title: "Create agent",
description:
"Create a new Claude or Codex agent tied to a working directory. Optionally run an initial prompt immediately or create a git worktree for the agent.",
"Create an agent tied to a working directory. Requires provider/model, for example codex/gpt-5.4. Optionally run an initial prompt immediately or create a git worktree for the agent.",
inputSchema: createAgentInputSchema,
outputSchema: {
agentId: z.string(),
@@ -508,11 +587,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (callerAgentId) {
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
provider = callerArgs.provider ?? "claude";
const resolvedProviderModel = resolveRequiredProviderModel(callerArgs.provider);
provider = resolvedProviderModel.provider;
model = resolvedProviderModel.model;
initialPrompt = callerArgs.initialPrompt;
background = callerArgs.background ?? false;
normalizedTitle = callerArgs.title.trim();
model = callerArgs.model;
thinking = callerArgs.thinking;
labels = callerArgs.labels;
notifyOnFinish = callerArgs.notifyOnFinish ?? false;
@@ -533,11 +613,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
}
} else {
const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
provider = topLevelArgs.provider ?? "claude";
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
provider = resolvedProviderModel.provider;
model = resolvedProviderModel.model;
initialPrompt = topLevelArgs.initialPrompt;
background = topLevelArgs.background ?? false;
normalizedTitle = topLevelArgs.title.trim();
model = topLevelArgs.model;
thinking = topLevelArgs.thinking;
labels = topLevelArgs.labels;
notifyOnFinish = topLevelArgs.notifyOnFinish ?? false;
@@ -929,15 +1010,29 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
"list_agents",
{
title: "List agents",
description: "List all live agents managed by the server.",
description: "List recent agents as compact metadata.",
inputSchema: {
includeArchived: z.boolean().optional().default(false),
cwd: z.string().optional(),
sinceHours: z
.number()
.int()
.positive()
.max(24 * 30)
.optional()
.default(48),
statuses: z.array(AgentStatusEnum).optional(),
limit: z.number().int().positive().max(200).optional().default(50),
},
outputSchema: {
agents: z.array(AgentSnapshotPayloadSchema),
agents: z.array(AgentListItemPayloadSchema),
},
},
async ({ includeArchived }) => {
async ({ includeArchived = false, cwd, sinceHours = 48, statuses, limit = 50 }) => {
const callerCwd = callerAgentId ? resolveCallerAgent()?.cwd : undefined;
const requestedCwd = cwd?.trim() ? expandUserPath(cwd) : callerCwd;
const statusFilter = statuses && statuses.length > 0 ? new Set(statuses) : null;
const sinceMs = Date.now() - sinceHours * 60 * 60 * 1000;
const liveSnapshots = agentManager.listAgents();
const liveAgents = await Promise.all(
liveSnapshots.map((snapshot) =>
@@ -950,10 +1045,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.filter((record) => !record.internal && !liveIds.has(record.id))
.filter((record) => includeArchived || !record.archivedAt)
.map((record) => buildStoredAgentPayload(record, requireProviderRegistry(), childLogger));
const agents = [...liveAgents, ...storedAgents]
.map(toAgentListItemPayload)
.filter((agent) => !requestedCwd || isSameOrDescendantPath(requestedCwd, agent.cwd))
.filter((agent) => !statusFilter || statusFilter.has(agent.status))
.filter((agent) => !agent.archivedAt || resolveAgentListActivityTime(agent) >= sinceMs)
.sort(compareAgentListItems)
.slice(0, limit);
return {
content: [],
structuredContent: ensureValidJson({ agents: [...liveAgents, ...storedAgents] }),
structuredContent: ensureValidJson({ agents }),
};
},
);
@@ -1321,7 +1423,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
}
return { type: "agent" as const, agentId: callerAgentId };
})()
: resolveNewAgentScheduleTarget({ provider, cwd });
: (() => {
return resolveNewAgentScheduleTarget({ provider, cwd });
})();
const schedule = await scheduleService.create({
prompt: prompt.trim(),
+12 -29
View File
@@ -42,7 +42,7 @@ export const AgentSelectOptionSchema = z
label: z.string().nullish(),
description: z.string().nullish(),
isDefault: z.boolean().nullish(),
metadata: z.record(z.unknown()).nullish(),
metadata: z.record(z.string(), z.unknown()).nullish(),
})
.passthrough();
@@ -53,7 +53,7 @@ export const AgentModelSchema = z
label: z.string().nullish(),
description: z.string().nullish(),
isDefault: z.boolean().nullish(),
metadata: z.record(z.unknown()).nullish(),
metadata: z.record(z.string(), z.unknown()).nullish(),
thinkingOptions: z.array(AgentSelectOptionSchema).nullish(),
defaultThinkingOptionId: z.string().nullish(),
})
@@ -67,41 +67,24 @@ export interface ResolvedProviderModel {
model: string | undefined;
}
export function resolveProviderAndModel(params: {
provider?: string;
model?: string;
defaultProvider: AgentProvider;
}): ResolvedProviderModel {
const providerInput = params.provider?.trim() || params.defaultProvider;
const modelInput = params.model?.trim();
if (params.model !== undefined && !modelInput) {
throw new Error("model cannot be empty");
}
export function resolveRequiredProviderModel(
providerValue: string,
): Required<ResolvedProviderModel> {
const providerInput = providerValue.trim();
const slashIndex = providerInput.indexOf("/");
if (slashIndex === -1) {
return {
provider: providerInput as AgentProvider,
model: modelInput,
};
if (slashIndex <= 0 || slashIndex === providerInput.length - 1) {
throw new Error("provider must be provider/model, for example codex/gpt-5.4");
}
const provider = providerInput.slice(0, slashIndex).trim();
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
if (!provider || !modelFromProvider) {
throw new Error("provider must be <provider> or <provider>/<model>");
}
if (modelInput && modelInput !== modelFromProvider) {
throw new Error(
`Conflicting model values provided: provider specifies ${modelFromProvider}, but model specifies ${modelInput}`,
);
const model = providerInput.slice(slashIndex + 1).trim();
if (!provider || !model) {
throw new Error("provider must be provider/model, for example codex/gpt-5.4");
}
return {
provider: provider as AgentProvider,
model: modelInput ?? modelFromProvider,
model,
};
}
+14
View File
@@ -20,3 +20,17 @@ export function resolvePathFromBase(baseCwd: string, requestedPath: string): str
}
return resolve(baseCwd, trimmed);
}
export function isSameOrDescendantPath(basePath: string, candidatePath: string): boolean {
let normalizedBase = basePath.replace(/\\/g, "/").replace(/\/$/, "");
let normalizedCandidate = candidatePath.replace(/\\/g, "/").replace(/\/$/, "");
if (/^[a-zA-Z]:\//.test(normalizedBase) || /^[a-zA-Z]:\//.test(normalizedCandidate)) {
normalizedBase = normalizedBase.toLowerCase();
normalizedCandidate = normalizedCandidate.toLowerCase();
}
return (
normalizedCandidate === normalizedBase || normalizedCandidate.startsWith(normalizedBase + "/")
);
}
+26 -4
View File
@@ -397,7 +397,7 @@ const ToolCallBasePayloadSchema = z
callId: z.string(),
name: z.string(),
detail: ToolCallDetailPayloadSchema,
metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
.strict();
@@ -534,7 +534,7 @@ const AgentPersistenceHandleSchema: z.ZodType<AgentPersistenceHandle | null> = z
provider: AgentProviderSchema,
sessionId: z.string(),
nativeHandle: z.string().optional(),
metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
.nullable();
@@ -544,7 +544,7 @@ const AgentRuntimeInfoSchema: z.ZodType<AgentRuntimeInfo> = z.object({
model: z.string().nullable().optional(),
thinkingOptionId: z.string().nullable().optional(),
modeId: z.string().nullable().optional(),
extra: z.record(z.unknown()).optional(),
extra: z.record(z.string(), z.unknown()).optional(),
});
export const AgentSnapshotPayloadSchema = z.object({
@@ -568,7 +568,7 @@ export const AgentSnapshotPayloadSchema = z.object({
lastUsage: AgentUsageSchema.optional(),
lastError: z.string().optional(),
title: z.string().nullable(),
labels: z.record(z.string()).default({}),
labels: z.record(z.string(), z.string()).default({}),
requiresAttention: z.boolean().optional(),
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
attentionTimestamp: z.string().nullable().optional(),
@@ -577,6 +577,28 @@ export const AgentSnapshotPayloadSchema = z.object({
export type AgentSnapshotPayload = z.infer<typeof AgentSnapshotPayloadSchema>;
export const AgentListItemPayloadSchema = z.object({
id: z.string(),
shortId: z.string(),
title: z.string().nullable(),
provider: AgentProviderSchema,
model: z.string().nullable(),
thinkingOptionId: z.string().nullable().optional(),
effectiveThinkingOptionId: z.string().nullable().optional(),
status: AgentStatusSchema,
cwd: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
lastUserMessageAt: z.string().nullable(),
archivedAt: z.string().nullable().optional(),
requiresAttention: z.boolean().optional(),
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
attentionTimestamp: z.string().nullable().optional(),
labels: z.record(z.string(), z.string()).default({}),
});
export type AgentListItemPayload = z.infer<typeof AgentListItemPayloadSchema>;
export type AgentStreamEventPayload = z.infer<typeof AgentStreamEventPayloadSchema>;
// ============================================================================
+1 -1
View File
@@ -1,7 +1,7 @@
import { resolve, dirname, basename } from "path";
import { existsSync, realpathSync } from "fs";
import { open as openFile, stat as statFile } from "fs/promises";
import { TTLCache } from "@isaacs/ttlcache";
import TTLCache from "@isaacs/ttlcache";
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
import {