diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index 628b492ce..c592ce5f8 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -90,6 +90,7 @@ async function spawnSession( openTab?: boolean, agent?: string, claimOptions?: SpawnClaimOptions, + prompt?: string, ): Promise { const spinner = ora("Creating session").start(); @@ -101,6 +102,7 @@ async function spawnSession( projectId, issueId, agent, + prompt, }); let claimedPrUrl: string | null = null; @@ -170,6 +172,7 @@ export function registerSpawn(program: Command): void { .option("--assign-on-github", "Assign the claimed PR to the authenticated GitHub user") .option("--decompose", "Decompose issue into subtasks before spawning") .option("--max-depth ", "Max decomposition depth (default: 3)") + .option("--prompt ", "Initial prompt/instructions for the agent (use instead of an issue)") .action( async ( first: string | undefined, @@ -181,6 +184,7 @@ export function registerSpawn(program: Command): void { assignOnGithub?: boolean; decompose?: boolean; maxDepth?: string; + prompt?: string; }, ) => { // Catch old two-arg usage: ao spawn @@ -256,7 +260,7 @@ export function registerSpawn(program: Command): void { if (leaves.length <= 1) { console.log(chalk.yellow("Task is atomic — spawning directly.")); - await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions); + await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions, opts.prompt); } else { // Create child issues and spawn sessions with lineage context const sm = await getSessionManager(config); @@ -283,7 +287,7 @@ export function registerSpawn(program: Command): void { } } } else { - await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions); + await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions, opts.prompt); } } catch (err) { console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`)); diff --git a/packages/core/src/orchestrator-prompt.ts b/packages/core/src/orchestrator-prompt.ts index 37df887a4..043931c99 100644 --- a/packages/core/src/orchestrator-prompt.ts +++ b/packages/core/src/orchestrator-prompt.ts @@ -60,6 +60,9 @@ ao spawn INT-1234 ao spawn --claim-pr 123 ao batch-spawn INT-1 INT-2 INT-3 +# Spawn a session without a tracker issue (prompt-driven) +ao spawn --prompt "Refactor the auth module to use JWT" + # List sessions ao session ls -p ${projectId} @@ -82,7 +85,7 @@ ao open ${projectId} | Command | Description | |---------|-------------| | \`ao status\` | Show all sessions with PR/CI/review status | -| \`ao spawn [issue] [--claim-pr ]\` | Spawn a worker session (project auto-detected), optionally attached to an existing PR | +| \`ao spawn [issue] [--prompt ] [--claim-pr ]\` | Spawn a worker session; use issue ID or --prompt for freeform tasks | | \`ao batch-spawn \` | Spawn multiple sessions in parallel (project auto-detected) | | \`ao session ls [-p project]\` | List all sessions (optionally filter by project) | | \`ao session claim-pr [session]\` | Attach an existing PR to a worker session | @@ -101,11 +104,16 @@ ao open ${projectId} When you spawn a session: 1. A git worktree is created from \`${project.defaultBranch}\` -2. A feature branch is created (e.g., \`feat/INT-1234\`) +2. A feature branch is created (e.g., \`feat/INT-1234\` for issues, \`session/\` for prompt-driven) 3. A tmux session is started (e.g., \`${project.sessionPrefix}-1\`) -4. The agent is launched with context about the issue +4. The agent is launched with context about the issue or prompt 5. Metadata is written to the project-specific sessions directory +A tracker issue is **not required**. Use \`--prompt\` to spawn freeform sessions: +\`\`\`bash +ao spawn --prompt "Add rate limiting to the /api/upload endpoint" +\`\`\` + ### Monitoring Progress Use \`ao status\` to see: diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 1d9765b63..d9516816a 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -1164,6 +1164,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM createdAt: new Date().toISOString(), runtimeHandle: JSON.stringify(handle), opencodeSessionId: reusedOpenCodeSessionId, + userPrompt: spawnConfig.prompt, }); if (plugins.agent.postLaunchSetup) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 463dc02ea..20f54beb8 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1389,6 +1389,7 @@ export interface SessionMetadata { directTerminalWsPort?: number; opencodeSessionId?: string; pinnedSummary?: string; // First quality summary, pinned for display stability + userPrompt?: string; // Prompt used when spawning without a tracker issue } // ============================================================================= diff --git a/packages/web/src/app/api/spawn/route.ts b/packages/web/src/app/api/spawn/route.ts index 0b97d1fca..1e41d431a 100644 --- a/packages/web/src/app/api/spawn/route.ts +++ b/packages/web/src/app/api/spawn/route.ts @@ -1,5 +1,5 @@ import { type NextRequest } from "next/server"; -import { validateIdentifier, validateConfiguredProject } from "@/lib/validation"; +import { validateIdentifier, validateString, validateConfiguredProject } from "@/lib/validation"; import { getServices } from "@/lib/services"; import { sessionToDashboard } from "@/lib/serialize"; import { getCorrelationId, jsonWithCorrelation, recordApiObservation } from "@/lib/observability"; @@ -25,6 +25,13 @@ export async function POST(request: NextRequest) { } } + if (body.prompt !== undefined && body.prompt !== null) { + const promptErr = validateString(body.prompt, "prompt", 4096); + if (promptErr) { + return jsonWithCorrelation({ error: promptErr }, { status: 400 }, correlationId); + } + } + try { const { config, sessionManager } = await getServices(); const projectId = body.projectId as string; @@ -48,6 +55,7 @@ export async function POST(request: NextRequest) { const session = await sessionManager.spawn({ projectId, issueId: (body.issueId as string) ?? undefined, + prompt: (body.prompt as string) ?? undefined, }); recordApiObservation({ diff --git a/packages/web/src/components/SessionCard.tsx b/packages/web/src/components/SessionCard.tsx index dc86cb295..083522d41 100644 --- a/packages/web/src/components/SessionCard.tsx +++ b/packages/web/src/components/SessionCard.tsx @@ -600,6 +600,15 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio > {session.issueLabel || session.issueUrl} + ) : session.userPrompt ? ( + + {session.userPrompt.length > 60 + ? session.userPrompt.slice(0, 60) + "…" + : session.userPrompt} + ) : ( {session.activity ?? session.status} diff --git a/packages/web/src/lib/format.ts b/packages/web/src/lib/format.ts index a2928a2c3..a2fed9ffe 100644 --- a/packages/web/src/lib/format.ts +++ b/packages/web/src/lib/format.ts @@ -43,21 +43,24 @@ export function getSessionTitle(session: DashboardSession): string { // 2. Issue title — human-written task description if (session.issueTitle) return session.issueTitle; - // 3. Humanized branch — stable semantic fallback + // 3. User prompt — freeform spawn instructions (prompt-only sessions have no issue) + if (session.userPrompt) return session.userPrompt; + + // 4. Humanized branch — stable semantic fallback if (session.branch) return humanizeBranch(session.branch); - // 4. Pinned summary — first quality summary, stable across agent updates + // 5. Pinned summary — first quality summary, stable across agent updates const pinnedSummary = session.metadata["pinnedSummary"]; if (pinnedSummary) return pinnedSummary; - // 5. Quality summary — skip fallback summaries (truncated spawn prompts) + // 6. Quality summary — skip fallback summaries (truncated spawn prompts) if (session.summary && !session.summaryIsFallback) { return session.summary; } - // 6. Any summary — even fallback excerpts beat raw status text + // 7. Any summary — even fallback excerpts beat raw status text if (session.summary) return session.summary; - // 7. Status + // 8. Status return session.status; } diff --git a/packages/web/src/lib/serialize.ts b/packages/web/src/lib/serialize.ts index d24372b6a..7bb15244b 100644 --- a/packages/web/src/lib/serialize.ts +++ b/packages/web/src/lib/serialize.ts @@ -62,6 +62,7 @@ export function sessionToDashboard(session: Session): DashboardSession { issueUrl: session.issueId, // issueId is actually the full URL issueLabel: null, // Will be enriched by enrichSessionIssue() issueTitle: null, // Will be enriched by enrichSessionIssueTitle() + userPrompt: session.metadata["userPrompt"] ?? null, summary, summaryIsFallback: agentSummary ? (session.agentInfo?.summaryIsFallback ?? false) : false, createdAt: session.createdAt.toISOString(), diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index 47a7e8594..b294c69b3 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -65,6 +65,7 @@ export interface DashboardSession { issueUrl: string | null; // Full issue URL issueLabel: string | null; // Human-readable label (e.g., "INT-1327", "#42") issueTitle: string | null; // Full issue title (e.g., "Add user authentication flow") + userPrompt: string | null; // Prompt used when spawning without an issue summary: string | null; /** True when the summary is a low-quality fallback (e.g. truncated spawn prompt) */ summaryIsFallback: boolean;