From a8181c9bc7fde9ae3ee9876931efc4ec19369ffb Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sat, 25 Apr 2026 19:42:30 -0600 Subject: [PATCH 1/3] Expand create agent startup context --- apps/server/src/agents/manager.ts | 135 +++++++- apps/server/src/server.ts | 270 +++++++++++---- .../components/app/create-agent-dialog.tsx | 325 +++++++++++++++--- apps/web/src/components/app/docs-pane.tsx | 8 +- apps/web/src/lib/api.ts | 4 +- e2e/terminal-agent-type.spec.ts | 24 +- 6 files changed, 646 insertions(+), 120 deletions(-) diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts index 05f99966..a53d15a3 100644 --- a/apps/server/src/agents/manager.ts +++ b/apps/server/src/agents/manager.ts @@ -182,6 +182,13 @@ type CreateAgentInput = { cliSessionId?: string; jobRunId?: string; initialPrompt?: string; + initialPins?: AgentPin[]; + initialFiles?: Array<{ + fileName: string; + buffer: Buffer; + source: "text" | "user"; + description?: string | null; + }>; }; type WorktreeCleanupMode = "auto" | "keep" | "force"; @@ -543,6 +550,7 @@ export class AgentManager { const tmuxSession = this.toSessionName(id, name); const mediaDir = path.join(this.config.mediaRoot, id); await mkdir(mediaDir, { recursive: true }); + const initialPins = input.initialPins ?? []; const useWorktree = input.useWorktree !== false; const createNewBranch = input.createNewBranch ?? true; @@ -617,8 +625,8 @@ export class AgentManager { const initialSetupPhase: SetupPhase = useWorktree ? "worktree" : "session"; await this.pool.query( ` - INSERT INTO agents (id, name, type, role, status, cwd, tmux_session, media_dir, codex_args, full_access, setup_phase, persona, parent_agent_id, persona_context, review_agent_type, cli_session_id, auto_review, base_branch, updated_at) - VALUES ($1, $2, $3, $4, 'creating', $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW()) + INSERT INTO agents (id, name, type, role, status, cwd, tmux_session, media_dir, codex_args, full_access, setup_phase, persona, parent_agent_id, persona_context, review_agent_type, cli_session_id, auto_review, base_branch, pins, updated_at) + VALUES ($1, $2, $3, $4, 'creating', $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18::jsonb, NOW()) `, [ id, @@ -638,9 +646,20 @@ export class AgentManager { cliSessionId, input.autoReview ?? false, normalizedBaseBranch ?? null, + JSON.stringify(initialPins), ] ); + const initialMedia = + input.initialFiles && input.initialFiles.length > 0 + ? await this.seedInitialMedia(id, mediaDir, input.initialFiles) + : []; + const startupPrompt = this.buildStartupPrompt( + input.initialPrompt, + initialPins, + initialMedia + ); + if (this.config.agentRuntime === "inert") { // Inert mode: no tmux, no setup script — do worktree synchronously and go straight to running let effectiveCwd = originalCwd; @@ -719,7 +738,7 @@ export class AgentManager { jobRunId: input.jobRunId, }), !input.persona && !input.jobRunId && (input.autoReview ?? false), - input.initialPrompt + startupPrompt ); const exitFile = `/tmp/dispatch_${tmuxSession}.exit`; @@ -2547,6 +2566,116 @@ export class AgentManager { return `${codexEnvPrefix} ${this.shellEscape(cliBin)} ${codexMcpFlags} ${escaped} ${this.shellEscape(startupPrompt)}`; } + private buildStartupPrompt( + initialPrompt: string | undefined, + initialPins: AgentPin[], + initialMedia: Array<{ + fileName: string; + source: string; + description: string | null; + }> + ): string | undefined { + const trimmedPrompt = initialPrompt?.trim() || ""; + if (initialPins.length === 0 && initialMedia.length === 0) { + return trimmedPrompt || undefined; + } + + const sections = [ + "Startup context is attached to this session.", + "Inspect the provided pins and shared media before acting, acknowledge what you were able to access, incorporate that context into the task, and continue unless the instructions explicitly ask you to pause or wait for confirmation.", + ]; + + if (trimmedPrompt) { + sections.push(`Instructions:\n${trimmedPrompt}`); + } + + if (initialPins.length > 0) { + sections.push( + [ + "Links:", + ...initialPins.map((pin) => `- ${pin.label}: ${pin.value}`), + ].join("\n") + ); + } + + if (initialMedia.length > 0) { + sections.push( + [ + "Files shared into Dispatch media:", + ...initialMedia.map((file) => { + const detail = file.description?.trim(); + const suffix = detail ? ` — ${detail}` : ""; + return `- ${file.fileName} (${file.source})${suffix}`; + }), + ].join("\n") + ); + } + + return sections.join("\n\n"); + } + + private async seedInitialMedia( + agentId: string, + mediaDir: string, + files: Array<{ + fileName: string; + buffer: Buffer; + source: "text" | "user"; + description?: string | null; + }> + ): Promise< + Array<{ fileName: string; source: string; description: string | null }> + > { + const createdAt = new Date(); + const results: Array<{ + fileName: string; + source: string; + description: string | null; + }> = []; + + for (const [index, file] of files.entries()) { + const timestampedFileName = this.timestampMediaFileName( + file.fileName, + createdAt, + index + ); + await writeFile(path.join(mediaDir, timestampedFileName), file.buffer); + await this.pool.query( + `INSERT INTO media (agent_id, file_name, source, size_bytes, description) + VALUES ($1, $2, $3, $4, $5)`, + [ + agentId, + timestampedFileName, + file.source, + file.buffer.length, + file.description ?? null, + ] + ); + results.push({ + fileName: timestampedFileName, + source: file.source, + description: file.description ?? null, + }); + } + + return results; + } + + private timestampMediaFileName( + fileName: string, + createdAt: Date, + index: number + ): string { + const timestamp = createdAt + .toISOString() + .replace(/[:.]/g, "-") + .replace("T", "-") + .replace("Z", ""); + const ext = path.extname(fileName); + const base = path.basename(fileName, ext); + return `${base}-${timestamp}-${index + 1}${ext}`; + } + private dispatchMcpUrl(agentId: string, jobRunId?: string): string { const path = jobRunId ? `/api/mcp/jobs/${jobRunId}/${agentId}` diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 65c14c99..f46ec968 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -161,6 +161,147 @@ jobService.onRunStateChange((run) => { const WEB_NOTIFY_ACK_TIMEOUT_MS = 3_000; const pendingWebNotifications = new Map(); +type CreateAgentBody = { + name?: unknown; + type?: unknown; + cwd?: unknown; + agentArgs?: unknown; + codexArgs?: unknown; + fullAccess?: unknown; + useWorktree?: unknown; + createNewBranch?: unknown; + worktreeBranch?: unknown; + baseBranch?: unknown; + persona?: unknown; + parentAgentId?: unknown; + personaContext?: unknown; + autoReview?: unknown; + initialPrompt?: unknown; + startupLinks?: unknown; +}; + +type StartupFileUpload = { + fileName: string; + buffer: Buffer; + source: "text" | "user"; + description: string | null; +}; + +function parseOptionalBooleanField( + value: unknown, + fieldName: string, + allowStringCoercion: boolean +): boolean | undefined { + if (value === undefined) return undefined; + if (typeof value === "boolean") return value; + if (allowStringCoercion && value === "true") return true; + if (allowStringCoercion && value === "false") return false; + throw new Error(`${fieldName} must be a boolean when provided.`); +} + +function parseOptionalStringArrayField( + value: unknown, + fieldName: string, + allowStringCoercion: boolean +): string[] | undefined { + if (value === undefined) return undefined; + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + return value; + } + if (!allowStringCoercion || typeof value !== "string") { + throw new Error(`${fieldName} must be an array of strings.`); + } + try { + const parsed = JSON.parse(value) as unknown; + if ( + Array.isArray(parsed) && + parsed.every((item) => typeof item === "string") + ) { + return parsed; + } + } catch {} + throw new Error(`${fieldName} must be an array of strings.`); +} + +function createStartupPins(urls: string[]): Array<{ + label: string; + value: string; + type: "url"; +}> { + const counts = new Map(); + return urls.map((rawUrl) => { + validatePinValue("url", rawUrl); + const hostname = new URL(rawUrl).hostname.replace(/^www\./, "") || "Link"; + const seen = counts.get(hostname) ?? 0; + counts.set(hostname, seen + 1); + return { + label: seen === 0 ? hostname : `${hostname} ${seen + 1}`, + value: rawUrl, + type: "url", + }; + }); +} + +async function parseCreateAgentRequest(request: { + body?: unknown; + isMultipart: () => boolean; + parts: () => AsyncIterable; +}): Promise<{ + body: CreateAgentBody; + startupFiles: StartupFileUpload[]; + isMultipart: boolean; +}> { + const multipart = request.isMultipart(); + if (!multipart) { + return { + body: (request.body as CreateAgentBody | undefined) ?? {}, + startupFiles: [], + isMultipart: false, + }; + } + + const body: CreateAgentBody = {}; + const startupFiles: StartupFileUpload[] = []; + + for await (const rawPart of request.parts()) { + const part = rawPart as { + type: "file" | "field"; + fieldname: string; + filename?: string; + value?: unknown; + toBuffer?: () => Promise; + }; + if (part.type === "file") { + if (part.fieldname !== "startupFiles") { + throw new Error("Unexpected file field."); + } + const fileName = path.basename(part.filename || ""); + if (!/^[A-Za-z0-9._-]+$/.test(fileName)) { + throw new Error("Invalid file name."); + } + if (!isMediaFile(fileName)) { + throw new Error( + "Unsupported file type. Use images (png/jpg/gif/webp), video (mp4), documents (pdf), or text files (txt/md/json/yaml/ts/py/etc)." + ); + } + if (!part.toBuffer) { + throw new Error("Invalid file upload."); + } + startupFiles.push({ + fileName, + buffer: await part.toBuffer(), + source: isTextFile(fileName) ? "text" : "user", + description: null, + }); + continue; + } + + body[part.fieldname as keyof CreateAgentBody] = part.value; + } + + return { body, startupFiles, isMultipart: true }; +} + /** Called by the ack endpoint when a client confirms delivery. */ function ackWebNotification(notificationId: string): boolean { const timer = pendingWebNotifications.get(notificationId); @@ -3830,23 +3971,19 @@ async function registerRoutes() { }); app.post("/api/v1/agents", async (request, reply) => { - const body = request.body as { - name?: unknown; - type?: unknown; - cwd?: unknown; - agentArgs?: unknown; - codexArgs?: unknown; - fullAccess?: unknown; - useWorktree?: unknown; - createNewBranch?: unknown; - worktreeBranch?: unknown; - baseBranch?: unknown; - persona?: unknown; - parentAgentId?: unknown; - personaContext?: unknown; - autoReview?: unknown; - initialPrompt?: unknown; + let parsedRequest: { + body: CreateAgentBody; + startupFiles: StartupFileUpload[]; + isMultipart: boolean; }; + try { + parsedRequest = await parseCreateAgentRequest(request); + } catch (error) { + return reply.code(400).send({ + error: error instanceof Error ? error.message : "Invalid request body.", + }); + } + const { body, startupFiles } = parsedRequest; if (typeof body?.cwd !== "string") { return reply @@ -3854,16 +3991,51 @@ async function registerRoutes() { .send({ error: "Body must include cwd as a string." }); } - const providedAgentArgs = body.agentArgs ?? body.codexArgs; - const agentArgsValid = - providedAgentArgs === undefined || - (Array.isArray(providedAgentArgs) && - providedAgentArgs.every((item) => typeof item === "string")); + let parsedAgentArgs: string[] | undefined; + let startupLinks: string[] | undefined; + let fullAccess: boolean | undefined; + let useWorktree: boolean | undefined; + let createNewBranch: boolean | undefined; + let autoReview: boolean | undefined; - if (!agentArgsValid) { + try { + parsedAgentArgs = parseOptionalStringArrayField( + body.agentArgs ?? body.codexArgs, + "agentArgs", + parsedRequest.isMultipart + ); + startupLinks = parseOptionalStringArrayField( + body.startupLinks, + "startupLinks", + parsedRequest.isMultipart + ); + fullAccess = parseOptionalBooleanField( + body.fullAccess, + "fullAccess", + parsedRequest.isMultipart + ); + useWorktree = parseOptionalBooleanField( + body.useWorktree, + "useWorktree", + parsedRequest.isMultipart + ); + createNewBranch = parseOptionalBooleanField( + body.createNewBranch, + "createNewBranch", + parsedRequest.isMultipart + ); + autoReview = parseOptionalBooleanField( + body.autoReview, + "autoReview", + parsedRequest.isMultipart + ); + } catch (error) { return reply .code(400) - .send({ error: "agentArgs must be an array of strings." }); + .send({ + error: + error instanceof Error ? error.message : "Invalid request body.", + }); } if ( @@ -3879,36 +4051,6 @@ async function registerRoutes() { }); } - if (body.fullAccess !== undefined && typeof body.fullAccess !== "boolean") { - return reply - .code(400) - .send({ error: "fullAccess must be a boolean when provided." }); - } - - if ( - body.useWorktree !== undefined && - typeof body.useWorktree !== "boolean" - ) { - return reply - .code(400) - .send({ error: "useWorktree must be a boolean when provided." }); - } - - if ( - body.createNewBranch !== undefined && - typeof body.createNewBranch !== "boolean" - ) { - return reply - .code(400) - .send({ error: "createNewBranch must be a boolean when provided." }); - } - - if (body.autoReview !== undefined && typeof body.autoReview !== "boolean") { - return reply - .code(400) - .send({ error: "autoReview must be a boolean when provided." }); - } - if ( body.worktreeBranch !== undefined && typeof body.worktreeBranch !== "string" @@ -3942,7 +4084,6 @@ async function registerRoutes() { }); } - const agentArgs = providedAgentArgs as string[] | undefined; const agentType = body.type === "claude" ? "claude" @@ -3970,11 +4111,12 @@ async function registerRoutes() { ? CODEX_FULL_ACCESS_ARG : null; const resolvedAgentArgs = - !isTerminalAgent && body.fullAccess === true && fullAccessArg - ? Array.from(new Set([...(agentArgs ?? []), fullAccessArg])) - : agentArgs; + !isTerminalAgent && fullAccess === true && fullAccessArg + ? Array.from(new Set([...(parsedAgentArgs ?? []), fullAccessArg])) + : parsedAgentArgs; try { + const startupPins = createStartupPins(startupLinks ?? []); const worktreeLocationRaw = await getSetting(pool, WORKTREE_LOCATION_KEY); const worktreeLocation: WorktreeLocation = worktreeLocationRaw && @@ -3987,13 +4129,9 @@ async function registerRoutes() { type: agentType, cwd: body.cwd, agentArgs: resolvedAgentArgs, - fullAccess: !isTerminalAgent && body.fullAccess === true, - useWorktree: - typeof body.useWorktree === "boolean" ? body.useWorktree : undefined, - createNewBranch: - typeof body.createNewBranch === "boolean" - ? body.createNewBranch - : undefined, + fullAccess: !isTerminalAgent && fullAccess === true, + useWorktree, + createNewBranch, worktreeBranch: typeof body.worktreeBranch === "string" ? body.worktreeBranch @@ -4010,11 +4148,13 @@ async function registerRoutes() { typeof body.personaContext === "string" ? body.personaContext : undefined, - autoReview: !isTerminalAgent && body.autoReview === true, + autoReview: !isTerminalAgent && autoReview === true, initialPrompt: !isTerminalAgent && typeof body.initialPrompt === "string" ? body.initialPrompt.trim() || undefined : undefined, + initialPins: !isTerminalAgent ? startupPins : [], + initialFiles: !isTerminalAgent ? startupFiles : [], }); queueGitContextRefresh([agent.id]); uiEventBroker.publish({ diff --git a/apps/web/src/components/app/create-agent-dialog.tsx b/apps/web/src/components/app/create-agent-dialog.tsx index dcead1b0..e81191f5 100644 --- a/apps/web/src/components/app/create-agent-dialog.tsx +++ b/apps/web/src/components/app/create-agent-dialog.tsx @@ -1,4 +1,6 @@ import { + type ChangeEvent, + type ClipboardEvent, type FormEvent, useCallback, useEffect, @@ -7,7 +9,16 @@ import { useState, } from "react"; import { useAtom } from "jotai"; -import { Check, ChevronDown, GitBranch, ChevronLeft } from "lucide-react"; +import { + Check, + ChevronDown, + GitBranch, + ChevronLeft, + Link2, + Paperclip, + Plus, + X, +} from "lucide-react"; import { BranchSelect } from "@/components/app/branch-select"; import { PathInput } from "@/components/app/path-input"; @@ -47,6 +58,12 @@ const CWD_HISTORY_MAX = 20; const FULL_ACCESS_PREFIX = "dispatch:fullAccess:"; const AUTO_REVIEW_PREFIX = "dispatch:autoReview:"; const BASE_BRANCH_PREFIX = "dispatch:baseBranch:"; +const STARTUP_FILE_ACCEPT = + ".png,.jpg,.jpeg,.gif,.webp,.mp4,.pdf,.txt,.md,.json,.yaml,.yml,.toml,.csv,.log,.xml,.html,.css,.js,.jsx,.ts,.tsx,.py,.go,.rs,.sh,.sql,.diff,.patch,.env,.ini,.cfg,.conf,.swift,.kt,.java,.c,.cpp,.h,.hpp,.rb,.php,.lua,.zig,.nim,.r,.m,.ex,.exs,.erl,.hs"; + +function startupFileKey(file: File): string { + return `${file.name}:${file.size}:${file.lastModified}`; +} function readStoredString(key: string): string { if (typeof window === "undefined") return ""; @@ -211,8 +228,9 @@ function CreateAgentDialogContent({ resolveDefaultCwd, onCreated, }: Omit): JSX.Element { - const [step, setStep] = useState<"config" | "prompt">("config"); + const [step, setStep] = useState<"config" | "context">("config"); const promptTextareaRef = useRef(null); + const startupFileInputRef = useRef(null); const [createName, setCreateName] = useState(""); const [createType, setCreateType] = useState(() => { const preferred = initialAgentType ?? readLastUsedAgentType(); @@ -231,6 +249,9 @@ function CreateAgentDialogContent({ const [createWorktreeBranch, setCreateWorktreeBranch] = useState(""); const [cwdIsGitRepo, setCwdIsGitRepo] = useState(null); const [initialPrompt, setInitialPrompt] = useState(""); + const [startupFiles, setStartupFiles] = useState([]); + const [startupLinks, setStartupLinks] = useState([]); + const [linkDraft, setLinkDraft] = useState(""); const [creating, setCreating] = useState(false); const [cwdHistory, setCwdHistory] = useState(() => readCwdHistory() @@ -279,7 +300,7 @@ function CreateAgentDialogContent({ }, [createType, enabledAgentTypes]); useEffect(() => { - if (step === "prompt") { + if (step === "context") { requestAnimationFrame(() => promptTextareaRef.current?.focus()); } }, [step]); @@ -301,6 +322,66 @@ function CreateAgentDialogContent({ [] ); + const appendStartupFiles = useCallback((files: File[]) => { + if (files.length === 0) return; + setStartupFiles((current) => { + const next = [...current]; + const seen = new Set(current.map(startupFileKey)); + for (const file of files) { + const key = startupFileKey(file); + if (seen.has(key)) continue; + seen.add(key); + next.push(file); + } + return next; + }); + }, []); + + const handleStartupPaste = useCallback( + (event: ClipboardEvent) => { + const pastedFiles = Array.from(event.clipboardData.items) + .filter((item) => item.kind === "file") + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null); + if (pastedFiles.length === 0) return; + event.preventDefault(); + appendStartupFiles(pastedFiles); + }, + [appendStartupFiles] + ); + + const handleStartupFileChange = useCallback( + (event: ChangeEvent) => { + const selected = Array.from(event.target.files ?? []); + appendStartupFiles(selected); + event.target.value = ""; + }, + [appendStartupFiles] + ); + + const handleRemoveStartupFile = useCallback((fileToRemove: File) => { + setStartupFiles((current) => + current.filter( + (file) => startupFileKey(file) !== startupFileKey(fileToRemove) + ) + ); + }, []); + + const addStartupLink = useCallback(() => { + const trimmed = linkDraft.trim(); + if (!trimmed) return; + setStartupLinks((current) => + current.includes(trimmed) ? current : [...current, trimmed] + ); + setLinkDraft(""); + }, [linkDraft]); + + const handleRemoveStartupLink = useCallback((linkToRemove: string) => { + setStartupLinks((current) => + current.filter((link) => link !== linkToRemove) + ); + }, []); + const handleSubmit = useCallback( async (event: FormEvent) => { event.preventDefault(); @@ -314,27 +395,56 @@ function CreateAgentDialogContent({ // try to run git in a non-repo directory. const submitUseWorktree = cwdIsGitRepo === false ? false : createUseWorktree; - const payload = await api<{ agent: Agent }>("/api/v1/agents", { - method: "POST", - body: JSON.stringify({ - name: createName.trim(), - cwd, - type: createType, - fullAccess: createFullAccess, - autoReview: createAutoReview, - useWorktree: submitUseWorktree, - createNewBranch: submitUseWorktree ? createNewBranch : undefined, - worktreeBranch: - submitUseWorktree && createNewBranch - ? createWorktreeBranch.trim() || undefined - : undefined, - baseBranch: - submitUseWorktree && createBaseBranch !== "main" - ? createBaseBranch - : undefined, - initialPrompt: initialPrompt.trim() || undefined, - }), - }); + const payloadBase = { + name: createName.trim(), + cwd, + type: createType, + fullAccess: createFullAccess, + autoReview: createAutoReview, + useWorktree: submitUseWorktree, + createNewBranch: submitUseWorktree ? createNewBranch : undefined, + worktreeBranch: + submitUseWorktree && createNewBranch + ? createWorktreeBranch.trim() || undefined + : undefined, + baseBranch: + submitUseWorktree && createBaseBranch !== "main" + ? createBaseBranch + : undefined, + initialPrompt: initialPrompt.trim() || undefined, + }; + const resolvedStartupLinks = + step === "context" && linkDraft.trim() + ? Array.from(new Set([...startupLinks, linkDraft.trim()])) + : startupLinks; + const useStartupContext = + step === "context" && + (payloadBase.initialPrompt || + startupFiles.length > 0 || + resolvedStartupLinks.length > 0); + const payload = useStartupContext + ? await (async () => { + const formData = new FormData(); + for (const [key, value] of Object.entries(payloadBase)) { + if (value === undefined || value === "") continue; + formData.append(key, String(value)); + } + formData.append( + "startupLinks", + JSON.stringify(resolvedStartupLinks) + ); + for (const file of startupFiles) { + formData.append("startupFiles", file); + } + return api<{ agent: Agent }>("/api/v1/agents", { + method: "POST", + body: formData, + }); + })() + : await api<{ agent: Agent }>("/api/v1/agents", { + method: "POST", + body: JSON.stringify(payloadBase), + }); if (typeof window !== "undefined") { window.localStorage.setItem(LAST_USED_CWD_KEY, cwd); @@ -359,6 +469,10 @@ function CreateAgentDialogContent({ cwdIsGitRepo, initialPrompt, onCreated, + linkDraft, + startupFiles, + startupLinks, + step, ] ); @@ -373,7 +487,7 @@ function CreateAgentDialogContent({ if (typeDropdownOpen) { e.preventDefault(); } - if (step === "prompt") { + if (step === "context") { e.preventDefault(); setStep("config"); } @@ -688,10 +802,10 @@ function CreateAgentDialogContent({ variant="default" tabIndex={0} disabled={creating || !createCwd.trim()} - data-testid="create-agent-with-prompt" - onClick={() => setStep("prompt")} + data-testid="create-agent-with-context" + onClick={() => setStep("context")} > - Create with prompt + Create with context ) : null}