feat: add --branch/--prompt to spawn CLI + file-based prompt delivery

- Add --branch and --prompt options to `ao spawn` CLI command
- Write long prompts to file and use $(cat ...) to avoid tmux truncation
- Add promptFile field to AgentLaunchConfig, handled by all 4 agent plugins
- Extract shared parsePrUrl() helper to deduplicate PR URL parsing
- Fix duplicate React keys in CIBadge when CI checks share names

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-19 01:25:18 +05:30
parent 40c1906d41
commit 6d1f05f5a1
8 changed files with 69 additions and 33 deletions

View File

@ -11,6 +11,8 @@ async function spawnSession(
projectId: string,
issueId?: string,
openTab?: boolean,
branch?: string,
prompt?: string,
): Promise<string> {
const spinner = ora("Creating session").start();
@ -21,6 +23,8 @@ async function spawnSession(
const session = await sm.spawn({
projectId,
issueId,
branch,
prompt,
});
spinner.succeed(`Session ${chalk.green(session.id)} created`);
@ -58,7 +62,9 @@ export function registerSpawn(program: Command): void {
.argument("<project>", "Project ID from config")
.argument("[issue]", "Issue identifier (e.g. INT-1234, #42) - must exist in tracker")
.option("--open", "Open session in terminal tab")
.action(async (projectId: string, issueId: string | undefined, opts: { open?: boolean }) => {
.option("--branch <branch>", "Use existing branch instead of creating from issue")
.option("-p, --prompt <prompt>", "Initial prompt to send to the agent")
.action(async (projectId: string, issueId: string | undefined, opts: { open?: boolean; branch?: string; prompt?: string }) => {
const config = loadConfig();
if (!config.projects[projectId]) {
console.error(
@ -70,7 +76,7 @@ export function registerSpawn(program: Command): void {
}
try {
await spawnSession(config, projectId, issueId, opts.open);
await spawnSession(config, projectId, issueId, opts.open, opts.branch, opts.prompt);
} catch (err) {
console.error(chalk.red(`${err}`));
process.exit(1);

View File

@ -112,6 +112,24 @@ function validateStatus(raw: string | undefined): SessionStatus {
return "spawning";
}
/** Parse a PR URL (typically GitHub) into a PRInfo-like object, or return null. */
function parsePrUrl(prUrl: string | undefined, branch?: string): Session["pr"] {
if (!prUrl) return null;
const ghMatch = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
return {
number: ghMatch
? parseInt(ghMatch[3], 10)
: parseInt(prUrl.match(/\/(\d+)$/)?.[1] ?? "0", 10),
url: prUrl,
title: "",
owner: ghMatch?.[1] ?? "",
repo: ghMatch?.[2] ?? "",
branch: branch ?? "",
baseBranch: "",
isDraft: false,
};
}
/** Reconstruct a Session object from raw metadata key=value pairs. */
function metadataToSession(
sessionId: SessionId,
@ -126,25 +144,7 @@ function metadataToSession(
activity: null,
branch: meta["branch"] || null,
issueId: meta["issue"] || null,
pr: meta["pr"]
? (() => {
// Parse owner/repo from GitHub PR URL: https://github.com/owner/repo/pull/123
const prUrl = meta["pr"];
const ghMatch = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
return {
number: ghMatch
? parseInt(ghMatch[3], 10)
: parseInt(prUrl.match(/\/(\d+)$/)?.[1] ?? "0", 10),
url: prUrl,
title: "",
owner: ghMatch?.[1] ?? "",
repo: ghMatch?.[2] ?? "",
branch: meta["branch"] ?? "",
baseBranch: "",
isDraft: false,
};
})()
: null,
pr: parsePrUrl(meta["pr"], meta["branch"]),
workspacePath: meta["worktree"] || null,
runtimeHandle: meta["runtimeHandle"]
? safeJsonParse<RuntimeHandle>(meta["runtimeHandle"])
@ -446,12 +446,25 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
userPrompt: spawnConfig.prompt,
});
// Write long prompts to a file to avoid shell/tmux truncation
const finalPrompt = composedPrompt ?? spawnConfig.prompt;
let promptFile: string | undefined;
if (finalPrompt) {
const baseDir = config.configPath
? getProjectBaseDir(config.configPath, project.path)
: sessionsDir;
mkdirSync(baseDir, { recursive: true });
promptFile = join(baseDir, `${sessionId}-prompt.md`);
writeFileSync(promptFile, finalPrompt, "utf-8");
}
// Get agent launch config and create runtime — clean up workspace on failure
const agentLaunchConfig = {
sessionId,
projectConfig: project,
issueId: spawnConfig.issueId,
prompt: composedPrompt ?? spawnConfig.prompt,
prompt: finalPrompt,
promptFile,
permissions: project.agentConfig?.permissions,
model: project.agentConfig?.model,
};

View File

@ -341,6 +341,15 @@ export interface AgentLaunchConfig {
* - Codex/Aider: similar shell substitution
*/
systemPromptFile?: string;
/**
* Path to a file containing the initial prompt (-p).
* Preferred over inline `prompt` for long prompts to avoid shell/tmux truncation.
*
* When set, takes precedence over prompt.
* - Claude Code: -p "$(cat /path/to/file)"
* - Codex/Aider: similar shell substitution
*/
promptFile?: string;
}
export interface WorkspaceHooksConfig {

View File

@ -89,7 +89,9 @@ function createAiderAgent(): Agent {
parts.push("--system-prompt", shellEscape(config.systemPrompt));
}
if (config.prompt) {
if (config.promptFile) {
parts.push("--message", `"$(cat ${shellEscape(config.promptFile)})"`);
} else if (config.prompt) {
parts.push("--message", shellEscape(config.prompt));
}

View File

@ -605,7 +605,9 @@ function createClaudeCodeAgent(): Agent {
parts.push("--append-system-prompt", shellEscape(config.systemPrompt));
}
if (config.prompt) {
if (config.promptFile) {
parts.push("-p", `"$(cat ${shellEscape(config.promptFile)})"`);
} else if (config.prompt) {
parts.push("-p", shellEscape(config.prompt));
}

View File

@ -51,7 +51,9 @@ function createCodexAgent(): Agent {
parts.push("--system-prompt", shellEscape(config.systemPrompt));
}
if (config.prompt) {
if (config.promptFile) {
parts.push("--", `"$(cat ${shellEscape(config.promptFile)})"`);
} else if (config.prompt) {
// Use `--` to end option parsing so prompts starting with `-` aren't
// misinterpreted as flags.
parts.push("--", shellEscape(config.prompt));

View File

@ -37,7 +37,9 @@ function createOpenCodeAgent(): Agent {
getLaunchCommand(config: AgentLaunchConfig): string {
const parts: string[] = ["opencode"];
if (config.prompt) {
if (config.promptFile) {
parts.push("run", `"$(cat ${shellEscape(config.promptFile)})"`);
} else if (config.prompt) {
parts.push("run", shellEscape(config.prompt));
}

View File

@ -86,7 +86,7 @@ export function CICheckList({ checks, layout = "vertical" }: CICheckListProps) {
if (layout === "inline") {
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{sorted.map((check) => {
{sorted.map((check, i) => {
const { icon, color } = checkStatusIcon[check.status];
const inner = (
<span className="inline-flex items-center gap-1 text-xs">
@ -96,7 +96,7 @@ export function CICheckList({ checks, layout = "vertical" }: CICheckListProps) {
);
return check.url ? (
<a
key={check.name}
key={`${check.name}-${i}`}
href={check.url}
target="_blank"
rel="noopener noreferrer"
@ -105,7 +105,7 @@ export function CICheckList({ checks, layout = "vertical" }: CICheckListProps) {
{inner}
</a>
) : (
<span key={check.name}>{inner}</span>
<span key={`${check.name}-${i}`}>{inner}</span>
);
})}
</div>
@ -115,7 +115,7 @@ export function CICheckList({ checks, layout = "vertical" }: CICheckListProps) {
if (layout === "expanded") {
return (
<div className="space-y-1">
{sorted.map((check) => {
{sorted.map((check, i) => {
const { icon, color } = checkStatusIcon[check.status];
const inner = (
<span className="inline-flex items-center gap-1 text-xs">
@ -124,7 +124,7 @@ export function CICheckList({ checks, layout = "vertical" }: CICheckListProps) {
</span>
);
return (
<div key={check.name} className="flex items-center gap-2">
<div key={`${check.name}-${i}`} className="flex items-center gap-2">
{check.url ? (
<a
href={check.url}
@ -156,10 +156,10 @@ export function CICheckList({ checks, layout = "vertical" }: CICheckListProps) {
return (
<div className="space-y-1">
{sorted.map((check) => {
{sorted.map((check, i) => {
const { icon, color } = checkStatusIcon[check.status];
return (
<div key={check.name} className="flex items-center gap-2 text-xs">
<div key={`${check.name}-${i}`} className="flex items-center gap-2 text-xs">
<span style={{ color }} className="w-3.5 shrink-0 text-center">
{icon}
</span>