diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index c40d05e3..055a6f36 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -44,6 +44,16 @@ export type WorkspaceCreateResponse = WorkspaceDescriptor export type WorkspaceListResponse = WorkspaceDescriptor[] export type WorkspaceDetailResponse = WorkspaceDescriptor +export interface WorkspaceAgentCreateRequest { + name: string + contents: string +} + +export interface WorkspaceAgentCreateResponse { + name: string + path: string +} + export interface WorkspaceDeleteResponse { id: string status: WorkspaceStatus diff --git a/packages/server/src/filesystem/browser.ts b/packages/server/src/filesystem/browser.ts index 679a8882..7a8749ce 100644 --- a/packages/server/src/filesystem/browser.ts +++ b/packages/server/src/filesystem/browser.ts @@ -81,12 +81,13 @@ export class FileSystemBrowser { return { path: relativePath, absolutePath } } - writeFile(relativePath: string, contents: string): void { + writeFile(relativePath: string, contents: string, options: { overwrite?: boolean } = {}): void { if (this.unrestricted) { throw new Error("writeFile is not available in unrestricted mode") } const resolved = this.toRestrictedAbsolute(relativePath) - fs.writeFileSync(resolved, contents, "utf-8") + fs.mkdirSync(path.dirname(resolved), { recursive: true }) + fs.writeFileSync(resolved, contents, { encoding: "utf-8", flag: options.overwrite === false ? "wx" : "w" }) } readFile(relativePath: string): string { diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index 9f2a68a2..c02f4bbb 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -27,6 +27,11 @@ const WorkspaceFileContentBodySchema = z.object({ contents: z.string(), }) +const WorkspaceAgentCreateSchema = z.object({ + name: z.string().trim().regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/, "Invalid agent name"), + contents: z.string(), +}) + const WorktreeGitDiffQuerySchema = z.object({ path: z.string().trim().min(1, "Path is required"), originalPath: z.string().trim().optional(), @@ -137,6 +142,24 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { } }) + app.post<{ + Params: { id: string } + }>("/api/workspaces/:id/agents", async (request, reply) => { + try { + const body = WorkspaceAgentCreateSchema.parse(request.body ?? {}) + const relativePath = `.opencode/agents/${body.name}.md` + deps.workspaceManager.createFile(request.params.id, relativePath, body.contents) + reply.code(201) + return { name: body.name, path: relativePath } + } catch (error: any) { + if (error?.code === "EEXIST") { + reply.code(409) + return { error: "Agent already exists" } + } + return handleWorkspaceError(error, reply) + } + }) + app.get<{ Params: { id: string; slug: string } }>("/api/workspaces/:id/worktrees/:slug/git-status", async (request, reply) => { diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 063c2cbb..fec69109 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -89,6 +89,12 @@ export class WorkspaceManager { browser.writeFile(relativePath, contents) } + createFile(workspaceId: string, relativePath: string, contents: string): void { + const workspace = this.requireWorkspace(workspaceId) + const browser = new FileSystemBrowser({ rootDir: workspace.path }) + browser.writeFile(relativePath, contents, { overwrite: false }) + } + async create(folder: string, name?: string): Promise { const id = `${Date.now().toString(36)}` diff --git a/packages/ui/src/components/agent-selector.tsx b/packages/ui/src/components/agent-selector.tsx index b5c6d5da..c3191c57 100644 --- a/packages/ui/src/components/agent-selector.tsx +++ b/packages/ui/src/components/agent-selector.tsx @@ -1,10 +1,12 @@ import { Select } from "@kobalte/core/select" -import { For, Show, createEffect, createMemo } from "solid-js" +import { Show, createEffect, createMemo, createSignal } from "solid-js" import { agents, fetchAgents, sessions } from "../stores/sessions" import { ChevronDown } from "lucide-solid" import type { Agent } from "../types/session" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" +import { serverApi } from "../lib/api-client" +import { showToastNotification } from "../lib/notifications" const log = getLogger("session") @@ -17,6 +19,10 @@ interface AgentSelectorProps { export default function AgentSelector(props: AgentSelectorProps) { const { t } = useI18n() + const [newAgentName, setNewAgentName] = createSignal("") + const [newAgentDescription, setNewAgentDescription] = createSignal("") + const [newAgentPrompt, setNewAgentPrompt] = createSignal("") + const [isCreatingAgent, setIsCreatingAgent] = createSignal(false) const instanceAgents = () => agents().get(props.instanceId) || [] const session = createMemo(() => { @@ -65,6 +71,68 @@ export default function AgentSelector(props: AgentSelectorProps) { } } + const normalizedNewAgentName = createMemo(() => newAgentName().trim()) + const isNewAgentNameValid = createMemo(() => /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(normalizedNewAgentName())) + const canCreateAgent = createMemo(() => isNewAgentNameValid() && !isCreatingAgent()) + + const quoteYamlString = (value: string) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` + + const buildAgentMarkdown = (name: string) => { + const description = newAgentDescription().trim() || t("agentSelector.add.defaultDescription", { agent: name }) + const prompt = newAgentPrompt().trim() || t("agentSelector.add.defaultPrompt", { agent: name }) + return [ + "---", + `description: ${quoteYamlString(description)}`, + "mode: primary", + "---", + prompt, + "", + ].join("\n") + } + + const handleCreateAgent = async (event: SubmitEvent) => { + event.preventDefault() + const name = normalizedNewAgentName() + if (!isNewAgentNameValid()) { + showToastNotification({ message: t("agentSelector.add.invalidName"), variant: "error" }) + return + } + if (instanceAgents().some((agent) => agent.name === name)) { + showToastNotification({ message: t("agentSelector.add.duplicate", { agent: name }), variant: "error" }) + return + } + + setIsCreatingAgent(true) + try { + await serverApi.createWorkspaceAgent(props.instanceId, { + name, + contents: buildAgentMarkdown(name), + }) + const refreshedAgents = await fetchAgents(props.instanceId, { throwOnError: true }) + if (!refreshedAgents.some((agent) => agent.name === name)) { + throw new Error(`Created agent ${name} was not loaded by OpenCode`) + } + await props.onAgentChange(name) + setNewAgentName("") + setNewAgentDescription("") + setNewAgentPrompt("") + showToastNotification({ message: t("agentSelector.add.success", { agent: name }), variant: "success" }) + } catch (error) { + log.error("Failed to create agent", error) + if (error instanceof Error && error.message.includes("Agent already exists")) { + showToastNotification({ message: t("agentSelector.add.fileExists", { agent: name }), variant: "error" }) + return + } + if (error instanceof Error && error.message.includes("was not loaded by OpenCode")) { + showToastNotification({ message: t("agentSelector.add.notLoaded", { agent: name }), variant: "error" }) + return + } + showToastNotification({ message: t("agentSelector.add.error"), variant: "error" }) + } finally { + setIsCreatingAgent(false) + } + } + return (