feat(spawn): support prompt-driven sessions without a tracker issue (#974)

- Add --prompt <text> flag to `ao spawn` CLI
- Accept prompt field in POST /api/spawn web route
- Persist userPrompt to session metadata on spawn
- Add userPrompt to DashboardSession type and serialize.ts mapping
- Show userPrompt in SessionCard footer for prompt-only sessions
- Include userPrompt in SessionDetail headline fallback chain
- Update orchestrator-prompt.ts with prompt-driven spawn examples

Closes #974
This commit is contained in:
Dhruv Sharma 2026-04-09 17:32:26 +05:30
parent 9df82d57af
commit aa2177698a
9 changed files with 47 additions and 11 deletions

View File

@ -90,6 +90,7 @@ async function spawnSession(
openTab?: boolean,
agent?: string,
claimOptions?: SpawnClaimOptions,
prompt?: string,
): Promise<string> {
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 <n>", "Max decomposition depth (default: 3)")
.option("--prompt <text>", "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 <project> <issue>
@ -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)}`));

View File

@ -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 <pr>]\` | Spawn a worker session (project auto-detected), optionally attached to an existing PR |
| \`ao spawn [issue] [--prompt <text>] [--claim-pr <pr>]\` | Spawn a worker session; use issue ID or --prompt for freeform tasks |
| \`ao batch-spawn <issues...>\` | Spawn multiple sessions in parallel (project auto-detected) |
| \`ao session ls [-p project]\` | List all sessions (optionally filter by project) |
| \`ao session claim-pr <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/<id>\` 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:

View File

@ -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) {

View File

@ -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
}
// =============================================================================

View File

@ -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({

View File

@ -600,6 +600,15 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
>
{session.issueLabel || session.issueUrl}
</a>
) : session.userPrompt ? (
<span
className="min-w-0 truncate text-[11px] text-[var(--color-text-tertiary)]"
title={session.userPrompt}
>
{session.userPrompt.length > 60
? session.userPrompt.slice(0, 60) + "…"
: session.userPrompt}
</span>
) : (
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-tertiary)]">
{session.activity ?? session.status}

View File

@ -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;
}

View File

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

View File

@ -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;