fix(core): deliver prompt inline via positional arg, remove post-launch polling (#1583)

Claude Code's positional [prompt] argument keeps it in interactive mode;
only -p/--print triggers headless one-shot exit. The entire post-launch
polling mechanism was built on the wrong assumption.

Changes:
- Claude Code plugin: pass prompt as positional arg in getLaunchCommand
- Core types: remove promptDelivery field from Agent interface
- Session manager: remove post-launch polling/retry block
- Prompt builder: clarify wording ("title, description, and labels"
  instead of "full issue details"), unify # format
- Tracker-github: match prompt wording update
- CLI spawn: remove dead-code promptDelivered warning

Closes #1582
This commit is contained in:
Adil Shaikh 2026-05-08 17:44:10 +05:30 committed by GitHub
parent b9b20e19d1
commit 1981d471ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 115 additions and 502 deletions

View File

@ -248,17 +248,6 @@ async function spawnSession(
);
console.log(` View: ${chalk.dim(projectSessionUrl(port, projectId, session.id))}`);
// Warn if prompt delivery failed (for post-launch agents like Claude Code)
const promptDelivered = session.metadata?.promptDelivered;
if (promptDelivered === "false") {
console.warn(
chalk.yellow(
` ⚠ Prompt delivery failed — agent may be idle.\n` +
` Use '${chalk.cyan("ao send " + session.id + ' "message..."')}' to send instructions manually.`,
),
);
}
// Open terminal tab if requested
if (openTab) {
try {

View File

@ -60,7 +60,7 @@ describe("buildPrompt split output", () => {
expect(systemPrompt).not.toContain("## Additional Instructions");
expect(taskPrompt).toContain("Focus on the API layer only.");
expect(taskPrompt).not.toContain("Work on issue: INT-1343");
expect(taskPrompt).not.toContain("Work on issue #INT-1343");
expect(taskPrompt).not.toContain("Layered Prompt System");
});
@ -83,15 +83,16 @@ describe("buildPrompt", () => {
expect(taskPrompt).toBeUndefined();
});
it("includes base prompt when issue is provided", () => {
it("includes base prompt when issue is provided without context", () => {
const { systemPrompt, taskPrompt } = buildPrompt({
project,
projectId: "test-app",
issueId: "INT-1343",
});
expect(systemPrompt).toContain(BASE_AGENT_PROMPT);
expect(systemPrompt).toContain("Work on issue: INT-1343");
expect(taskPrompt).toBe("Work on issue: INT-1343");
expect(systemPrompt).toContain("Work on issue #INT-1343");
expect(taskPrompt).toContain("Work on issue #INT-1343");
expect(taskPrompt).toContain("Issue details were not pre-fetched");
});
it("includes project context", () => {
@ -115,18 +116,20 @@ describe("buildPrompt", () => {
expect(systemPrompt).not.toContain("Repository:");
});
it("includes issue ID in task section", () => {
it("tells agent to fetch issue when context is missing", () => {
const { systemPrompt, taskPrompt } = buildPrompt({
project,
projectId: "test-app",
issueId: "INT-1343",
});
expect(systemPrompt).toContain("Work on issue: INT-1343");
expect(systemPrompt).toContain("Work on issue #INT-1343");
expect(systemPrompt).toContain("feat/INT-1343");
expect(taskPrompt).toBe("Work on issue: INT-1343");
expect(taskPrompt).toContain("Work on issue #INT-1343");
expect(taskPrompt).toContain("Issue details were not pre-fetched");
expect(taskPrompt).toContain("gh issue view INT-1343");
});
it("includes issue context when provided", () => {
it("tells agent details are pre-fetched when context is provided", () => {
const { systemPrompt, taskPrompt } = buildPrompt({
project,
projectId: "test-app",
@ -136,10 +139,23 @@ describe("buildPrompt", () => {
expect(systemPrompt).toContain("## Issue Details");
expect(systemPrompt).toContain("Layered Prompt System");
expect(systemPrompt).toContain("Priority: High");
expect(taskPrompt).toBe("Work on issue: INT-1343");
expect(taskPrompt).toContain("Work on issue #INT-1343");
expect(taskPrompt).toContain("start implementing without re-fetching the issue");
expect(taskPrompt).not.toContain("Layered Prompt System");
});
it("normalizes issue ID with leading # to avoid double-hash", () => {
const { systemPrompt, taskPrompt } = buildPrompt({
project,
projectId: "test-app",
issueId: "#42",
});
expect(systemPrompt).toContain("Work on issue #42");
expect(systemPrompt).not.toContain("##42");
expect(taskPrompt).toContain("Work on issue #42");
expect(taskPrompt).not.toContain("##42");
});
it("includes inline agentRules", () => {
project.agentRules = "Always run pnpm test before pushing.";
const { systemPrompt } = buildPrompt({

View File

@ -1098,33 +1098,17 @@ describe("spawn", () => {
expect(session.branch).not.toBe("main");
});
it("sends prompt post-launch when agent.promptDelivery is 'post-launch'", async () => {
vi.useFakeTimers();
const postLaunchAgent = {
...mockAgent,
promptDelivery: "post-launch" as const,
};
const registryWithPostLaunch: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return postLaunchAgent;
if (slot === "workspace") return mockWorkspace;
return null;
it("passes prompt inline to agent via getLaunchCommand", async () => {
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" });
// Prompt should be passed to getLaunchCommand, not sent via runtime.sendMessage
expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining("Fix the bug"),
}),
};
const sm = createSessionManager({ config, registry: registryWithPostLaunch });
const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" });
await vi.advanceTimersByTimeAsync(5_000);
await spawnPromise;
// Prompt should be sent via runtime.sendMessage, not included in launch command
expect(mockRuntime.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ id: expect.any(String) }),
expect.stringContaining("Fix the bug"),
);
vi.useRealTimers();
expect(mockRuntime.sendMessage).not.toHaveBeenCalled();
});
it("writes worker system prompt to file and passes only explicit task prompt to agent", async () => {
@ -1156,7 +1140,7 @@ describe("spawn", () => {
expect(systemPrompt).toContain("Session Lifecycle");
expect(systemPrompt).toContain("## Project Context");
expect(systemPrompt).toContain("## Task");
expect(systemPrompt).toContain("Work on issue: INT-1343");
expect(systemPrompt).toContain("Work on issue #INT-1343");
expect(systemPrompt).not.toContain("## Additional Instructions");
});
@ -1227,149 +1211,61 @@ describe("spawn", () => {
expect(opencodeConfig.instructions[0]).toContain("worker-prompt-app-1.md");
const systemPromptPath = opencodeConfig.instructions[0]!;
expect(readFileSync(systemPromptPath, "utf-8")).toContain("Work on issue: INT-1343");
expect(readFileSync(systemPromptPath, "utf-8")).toContain("Work on issue #INT-1343");
expect(readFileSync(systemPromptPath, "utf-8")).not.toContain("## Additional Instructions");
const agentsMdPath = getWorkspaceAgentsMdPath(workspacePath);
expect(existsSync(agentsMdPath)).toBe(false);
});
it("does not send prompt post-launch when agent.promptDelivery is not set", async () => {
it("passes generated task prompt to agent for issue-only spawns", async () => {
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" });
await sm.spawn({ projectId: "my-app", issueId: "INT-1343" });
// Default agent (no promptDelivery) should NOT trigger sendMessage for prompt
expect(mockRuntime.sendMessage).not.toHaveBeenCalled();
});
it("does not send a post-launch message when no task prompt is available", async () => {
vi.useFakeTimers();
const postLaunchAgent = {
...mockAgent,
promptDelivery: "post-launch" as const,
};
const registryWithPostLaunch: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return postLaunchAgent;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
const sm = createSessionManager({ config, registry: registryWithPostLaunch });
const spawnPromise = sm.spawn({ projectId: "my-app" });
await vi.advanceTimersByTimeAsync(5_000);
const session = await spawnPromise;
expect(mockRuntime.sendMessage).not.toHaveBeenCalled();
expect(session.metadata.promptDelivered).toBeUndefined();
vi.useRealTimers();
});
it("sends a minimal post-launch task for issue-only spawns", async () => {
vi.useFakeTimers();
const postLaunchAgent = {
...mockAgent,
promptDelivery: "post-launch" as const,
};
const registryWithPostLaunch: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return postLaunchAgent;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
const sm = createSessionManager({ config, registry: registryWithPostLaunch });
const spawnPromise = sm.spawn({ projectId: "my-app", issueId: "INT-1343" });
await vi.advanceTimersByTimeAsync(5_000);
const session = await spawnPromise;
expect(mockRuntime.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ id: expect.any(String) }),
"Work on issue: INT-1343",
);
expect(session.metadata.promptDelivered).toBe("true");
const callArgs = vi.mocked(postLaunchAgent.getLaunchCommand).mock.calls[0][0];
expect(callArgs.prompt).toBe("Work on issue: INT-1343");
const callArgs = vi.mocked(mockAgent.getLaunchCommand).mock.calls[0][0];
expect(callArgs.prompt).toContain("Work on issue #INT-1343");
expect(callArgs.systemPromptFile).toContain("worker-prompt-app-1.md");
const systemPrompt = readFileSync(callArgs.systemPromptFile!, "utf-8");
expect(systemPrompt).toContain("## Task");
expect(systemPrompt).toContain("Work on issue: INT-1343");
vi.useRealTimers();
expect(systemPrompt).toContain("Work on issue #INT-1343");
});
it("does not destroy session when post-launch prompt delivery fails", async () => {
vi.useFakeTimers();
const failingRuntime: Runtime = {
it("installs workspace hooks before launching the agent", async () => {
const callOrder: string[] = [];
const trackingAgent = {
...mockAgent,
setupWorkspaceHooks: vi.fn().mockImplementation(() => {
callOrder.push("setupWorkspaceHooks");
return Promise.resolve();
}),
};
const trackingRuntime: Runtime = {
...mockRuntime,
sendMessage: vi.fn().mockRejectedValue(new Error("tmux send failed")),
create: vi.fn().mockImplementation((...args: Parameters<Runtime["create"]>) => {
callOrder.push("runtime.create");
return vi.mocked(mockRuntime.create)(...args);
}),
};
const postLaunchAgent = {
...mockAgent,
promptDelivery: "post-launch" as const,
};
const registryWithFailingSend: PluginRegistry = {
const trackingRegistry: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return failingRuntime;
if (slot === "agent") return postLaunchAgent;
if (slot === "runtime") return trackingRuntime;
if (slot === "agent") return trackingAgent;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
const sm = createSessionManager({ config, registry: registryWithFailingSend });
const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" });
// With retry logic (3 attempts at 3s, 6s, 9s delays before each attempt), need to advance 18s for all retries
await vi.advanceTimersByTimeAsync(18_000);
const session = await spawnPromise;
const sm = createSessionManager({ config, registry: trackingRegistry });
await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" });
// Session should still be returned successfully despite sendMessage failure
expect(session.id).toBe("app-1");
expect(session.status).toBe("spawning");
// Runtime should NOT have been destroyed
expect(failingRuntime.destroy).not.toHaveBeenCalled();
// Verify promptDelivered is set to false in metadata
expect(session.metadata.promptDelivered).toBe("false");
vi.useRealTimers();
}, 30_000);
it("waits before sending post-launch prompt", async () => {
vi.useFakeTimers();
const postLaunchAgent = {
...mockAgent,
promptDelivery: "post-launch" as const,
};
const registryWithPostLaunch: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return postLaunchAgent;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
const sm = createSessionManager({ config, registry: registryWithPostLaunch });
const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" });
// Advance only 2s — not enough, message should not have been sent yet
await vi.advanceTimersByTimeAsync(2_000);
expect(mockRuntime.sendMessage).not.toHaveBeenCalled();
// Advance the remaining 1s — now the first attempt should fire (3s total = 3000 * 1)
await vi.advanceTimersByTimeAsync(1_000);
await spawnPromise;
expect(mockRuntime.sendMessage).toHaveBeenCalled();
vi.useRealTimers();
}, 20_000);
const hooksIdx = callOrder.indexOf("setupWorkspaceHooks");
const createIdx = callOrder.indexOf("runtime.create");
expect(hooksIdx).toBeGreaterThanOrEqual(0);
expect(createIdx).toBeGreaterThanOrEqual(0);
expect(hooksIdx).toBeLessThan(createIdx);
});
describe("rollback on failure", () => {
it("cleans up reserved metadata when workspace creation fails", async () => {

View File

@ -116,10 +116,11 @@ function buildConfigLayer(config: PromptBuildConfig): string {
}
if (issueId) {
const normalizedId = issueId.replace(/^#/, "");
lines.push(`\n## Task`);
lines.push(`Work on issue: ${issueId}`);
lines.push(`Work on issue #${normalizedId}`);
lines.push(
`Create a branch named so that it auto-links to the issue tracker (e.g. feat/${issueId}).`,
`Create a branch named so that it auto-links to the issue tracker (e.g. feat/${normalizedId}).`,
);
}
@ -203,7 +204,9 @@ export function buildPrompt(
taskPrompt: config.userPrompt
? config.userPrompt
: config.issueId
? `Work on issue: ${config.issueId}`
? config.issueContext
? `Work on issue #${config.issueId.replace(/^#/, "")}. The issue title, description, and labels are already in your system prompt — start implementing without re-fetching the issue. Fetch comments or linked issues only if you need additional context.`
: `Work on issue #${config.issueId.replace(/^#/, "")}. Issue details were not pre-fetched — start by reading the issue (e.g. \`gh issue view ${config.issueId.replace(/^#/, "")}\`), then implement.`
: undefined,
};
}

View File

@ -1292,6 +1292,16 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
await plugins.agent.preLaunchSetup(workspacePath);
}
// Install workspace hooks before launching the agent so that
// PostToolUse hooks (e.g. Claude Code's metadata-updater) are
// in place before the agent's first tool call.
if (plugins.agent.setupWorkspaceHooks) {
await plugins.agent.setupWorkspaceHooks(workspacePath, { dataDir: sessionsDir });
}
if (plugins.agent.name !== "claude-code") {
await setupPathWrapperWorkspace(workspacePath);
}
const handle = await plugins.runtime.create({
sessionId: tmuxName ?? sessionId, // Use tmux name for runtime if available
workspacePath,
@ -1410,53 +1420,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
invalidateCache();
// Past this point every resource that needed an undo is on disk in its
// final form. Dismiss the stack so the prompt-delivery loop below (which
// is intentionally non-fatal) cannot trigger a rollback.
// final form. Dismiss the stack so nothing below can trigger a rollback.
cleanupStack.dismiss();
// Send the task-specific prompt post-launch for agents that need it
// (e.g. Claude Code exits after -p, so we send the prompt after it starts
// in interactive mode). Prompt delivery failure must NOT destroy the
// session — the agent is running; user can retry with `ao send`.
let promptDelivered = false;
if (plugins.agent.promptDelivery === "post-launch" && agentLaunchConfig.prompt) {
const maxRetries = 3;
const baseDelayMs = 3_000;
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// Wait for agent to start and be ready for input
// Use exponential backoff: 3s, 6s, 9s between attempts
await new Promise((resolve) => setTimeout(resolve, baseDelayMs * attempt));
await plugins.runtime.sendMessage(handle, agentLaunchConfig.prompt);
promptDelivered = true;
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
console.error(
`[session-manager] Prompt delivery attempt ${attempt}/${maxRetries} failed: ${lastError.message}`,
);
}
}
if (!promptDelivered) {
console.error(
`[session-manager] FAILED to deliver prompt to session ${sessionId} after ${maxRetries} attempts. ` +
`User must send manually with 'ao send'. Last error: ${lastError?.message}`,
);
}
session.metadata["promptDelivered"] = String(promptDelivered);
} else if (agentLaunchConfig.prompt) {
session.metadata["promptDelivered"] = "true";
}
if (session.metadata["promptDelivered"]) {
updateMetadata(sessionsDir, sessionId, session.metadata);
invalidateCache();
}
// Prompt is delivered inline via the agent's launch command (positional argument).
// No post-launch polling needed — the prompt is part of process invocation.
recordActivityEvent({
projectId: spawnConfig.projectId,
sessionId,

View File

@ -464,15 +464,6 @@ export interface Agent {
/** Process name to look for (e.g. "claude", "codex", "aider") */
readonly processName: string;
/**
* How the initial prompt should be delivered to the agent.
* - "inline" (default): prompt is included in the launch command (e.g. -p flag)
* - "post-launch": prompt is sent via runtime.sendMessage() after the agent starts,
* keeping the agent in interactive mode. Use this for agents where inlining
* the prompt causes one-shot/exit behavior (e.g. Claude Code's -p flag).
*/
readonly promptDelivery?: "inline" | "post-launch";
/** Get the shell command to launch this agent */
getLaunchCommand(config: AgentLaunchConfig): string;

View File

@ -1,256 +0,0 @@
/**
* Integration test: Claude Code prompt delivery `-p` (one-shot) vs interactive mode.
*
* Demonstrates the bug and the fix:
*
* 1. "claude -p exits after work" launches Claude with -p, proves the process
* exits after responding. This is the bug: the agent can't receive follow-ups.
*
* 2. "interactive claude stays alive" launches Claude without -p, delivers
* the prompt post-launch via runtime.sendMessage(), proves the process stays
* alive and can receive follow-up messages.
*
* Requires: tmux, claude binary, ANTHROPIC_API_KEY.
*/
import { execFile } from "node:child_process";
import { mkdtemp, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import claudeCodePlugin from "@aoagents/ao-plugin-agent-claude-code";
import runtimeTmuxPlugin from "@aoagents/ao-plugin-runtime-tmux";
import {
isTmuxAvailable,
killSessionsByPrefix,
createSession,
killSession,
capturePane,
} from "./helpers/tmux.js";
import { pollUntilEqual, sleep } from "./helpers/polling.js";
import { makeTmuxHandle } from "./helpers/session-factory.js";
const execFileAsync = promisify(execFile);
// ---------------------------------------------------------------------------
// Prerequisites
// ---------------------------------------------------------------------------
const SESSION_PREFIX = "ao-inttest-prompt-";
/** Lines of scrollback to capture — generous to account for Claude's TUI output. */
const CAPTURE_LINES = 500;
async function findClaudeBinary(): Promise<string | null> {
try {
await execFileAsync("which", ["claude"], { timeout: 5_000 });
return "claude";
} catch {
return null;
}
}
/**
* Wait for Claude Code's TUI to be ready for input.
* Polls the tmux pane for Claude's prompt character () on its own line.
* Returns false if the OAuth/login screen is detected instead.
*/
async function waitForTuiReady(
sessionName: string,
timeoutMs = 60_000,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const output = await capturePane(sessionName, CAPTURE_LINES);
// Bail early if OAuth/login screen is showing — TUI won't become ready
if (/sign in|oauth|Paste code here/i.test(output)) return false;
// Claude Code shows when idle. Check the last non-empty line specifically
// (not the end of the full output) to avoid matching "prompted >" etc.
const lastLine = output
.split("\n")
.filter((l) => l.trim().length > 0)
.pop();
if (lastLine && /^\s*\s*$/.test(lastLine)) return true;
await sleep(2_000);
}
return false;
}
const tmuxOk = await isTmuxAvailable();
const claudeBin = await findClaudeBinary();
const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY);
const canRun = tmuxOk && claudeBin !== null && hasApiKey;
// Interactive mode requires OAuth login. ANTHROPIC_API_KEY alone only works with -p.
// CI environments (GitHub Actions) typically only have API keys, not OAuth sessions.
const canRunInteractive = canRun && !process.env.CI;
// ---------------------------------------------------------------------------
// Test 1: -p flag causes Claude to exit (the bug)
// ---------------------------------------------------------------------------
describe.skipIf(!canRun)("claude -p exits after completing work (the bug)", () => {
const agent = claudeCodePlugin.create();
const sessionName = `${SESSION_PREFIX}print-${Date.now()}`;
let tmpDir: string;
beforeAll(async () => {
await killSessionsByPrefix(`${SESSION_PREFIX}print-`);
const raw = await mkdtemp(join(tmpdir(), "ao-inttest-prompt-print-"));
tmpDir = await realpath(raw);
// Create a bash session (keeps tmux pane alive after Claude exits)
await createSession(sessionName, "bash", tmpDir);
await sleep(500);
// Launch Claude with -p (print-and-exit mode)
await execFileAsync(
"tmux",
[
"send-keys",
"-t",
sessionName,
"-l",
"CLAUDECODE= claude --dangerously-skip-permissions -p 'Respond with exactly: SENTINEL_P_DELIVERED'",
],
{ timeout: 10_000 },
);
await execFileAsync("tmux", ["send-keys", "-t", sessionName, "Enter"], {
timeout: 5_000,
});
}, 30_000);
afterAll(async () => {
await killSession(sessionName);
if (tmpDir) await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}, 30_000);
it("claude exits after responding to -p prompt", async () => {
const handle = makeTmuxHandle(sessionName);
// Wait for Claude to start
await pollUntilEqual(() => agent.isProcessRunning(handle), true, {
timeoutMs: 30_000,
intervalMs: 1_000,
});
// Wait for it to exit — -p mode always exits after responding
const exited = await pollUntilEqual(() => agent.isProcessRunning(handle), false, {
timeoutMs: 90_000,
intervalMs: 2_000,
});
expect(exited).toBe(false);
// Verify the work was done (Claude did respond before exiting)
const output = await capturePane(sessionName, CAPTURE_LINES);
expect(output).toContain("SENTINEL_P_DELIVERED");
// This is the problem: process is gone, ao send has nothing to talk to
const running = await agent.isProcessRunning(handle);
expect(running).toBe(false);
}, 120_000);
});
// ---------------------------------------------------------------------------
// Test 2: interactive mode + post-launch prompt keeps Claude alive (the fix)
// ---------------------------------------------------------------------------
describe.skipIf(!canRunInteractive)(
"interactive claude stays alive after post-launch prompt (the fix)",
() => {
const agent = claudeCodePlugin.create();
const runtime = runtimeTmuxPlugin.create();
const sessionName = `${SESSION_PREFIX}interactive-${Date.now()}`;
let tmpDir: string;
const handle = makeTmuxHandle(sessionName);
beforeAll(async () => {
await killSessionsByPrefix(`${SESSION_PREFIX}interactive-`);
const raw = await mkdtemp(join(tmpdir(), "ao-inttest-prompt-interactive-"));
tmpDir = await realpath(raw);
// Create a bash session
await createSession(sessionName, "bash", tmpDir);
await sleep(500);
// Launch Claude WITHOUT -p (interactive mode) — this is the fix
await execFileAsync(
"tmux",
[
"send-keys",
"-t",
sessionName,
"-l",
"CLAUDECODE= claude --dangerously-skip-permissions",
],
{ timeout: 10_000 },
);
await execFileAsync("tmux", ["send-keys", "-t", sessionName, "Enter"], {
timeout: 5_000,
});
// Wait for Claude to start
await pollUntilEqual(() => agent.isProcessRunning(handle), true, {
timeoutMs: 30_000,
intervalMs: 1_000,
});
// Wait for Claude's TUI to be fully ready (shows prompt character )
await waitForTuiReady(sessionName, 60_000);
// Deliver the initial prompt post-launch (what the fix does)
await runtime.sendMessage(handle, "Respond with exactly: SENTINEL_I_DELIVERED");
// Wait for Claude to process the prompt and return to idle ()
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
const output = await capturePane(sessionName, CAPTURE_LINES);
// Check that Claude responded (sentinel in output) AND is back at idle prompt ()
const lastLine = output
.split("\n")
.filter((l) => l.trim().length > 0)
.pop();
if (output.includes("SENTINEL_I_DELIVERED") && lastLine && /^\s*\s*$/.test(lastLine)) {
break;
}
await sleep(2_000);
}
}, 180_000);
afterAll(async () => {
await killSession(sessionName);
if (tmpDir) await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}, 30_000);
it("prompt was delivered and processed", async () => {
const output = await capturePane(sessionName, CAPTURE_LINES);
expect(output).toContain("SENTINEL_I_DELIVERED");
});
it("claude is still running after completing the task", async () => {
const running = await agent.isProcessRunning(handle);
expect(running).toBe(true);
});
it("can receive and process a follow-up message", async () => {
await runtime.sendMessage(handle, "Respond with exactly: SENTINEL_FOLLOWUP_OK");
const deadline = Date.now() + 90_000;
let output = "";
while (Date.now() < deadline) {
output = await capturePane(sessionName, CAPTURE_LINES);
if (output.includes("SENTINEL_FOLLOWUP_OK")) break;
await sleep(2_000);
}
expect(output).toContain("SENTINEL_FOLLOWUP_OK");
// Still alive after follow-up
const stillRunning = await agent.isProcessRunning(handle);
expect(stillRunning).toBe(true);
}, 120_000);
},
);

View File

@ -199,7 +199,6 @@ describe("plugin manifest & exports", () => {
const agent = create();
expect(agent.name).toBe("claude-code");
expect(agent.processName).toBe("claude");
expect(agent.promptDelivery).toBe("post-launch");
});
it("default export is a valid PluginModule", () => {
@ -244,17 +243,22 @@ describe("getLaunchCommand", () => {
expect(cmd).toContain("--model 'claude-opus-4-6'");
});
it("does not include -p flag (prompt delivered post-launch)", () => {
it("includes prompt as positional argument with -- separator (not -p flag)", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Fix the bug" }));
expect(cmd).not.toContain("-p");
expect(cmd).not.toContain("Fix the bug");
expect(cmd).toContain("-- 'Fix the bug'");
});
it("combines all options without prompt", () => {
it("combines all options with prompt as positional arg after --", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ permissions: "permissionless", model: "opus", prompt: "Hello" }),
);
expect(cmd).toBe("claude --dangerously-skip-permissions --model 'opus'");
expect(cmd).toBe("claude --dangerously-skip-permissions --model 'opus' -- 'Hello'");
});
it("handles prompts starting with dashes safely via -- separator", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "--investigate this" }));
expect(cmd).toContain("-- '--investigate this'");
});
it("omits --dangerously-skip-permissions when permissions=default", () => {
@ -268,25 +272,24 @@ describe("getLaunchCommand", () => {
expect(cmd).not.toContain("-p");
});
it("includes --append-system-prompt alongside omitted -p", () => {
it("includes --append-system-prompt and prompt as positional arg after --", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ systemPrompt: "You are a helper", prompt: "Do the task" }),
);
expect(cmd).toContain("--append-system-prompt");
expect(cmd).toContain("You are a helper");
// -p as a standalone flag (not substring of --append-system-prompt)
expect(cmd).not.toMatch(/\s-p\s/);
expect(cmd).not.toContain("Do the task");
expect(cmd).toContain("-- 'Do the task'");
});
it("uses systemPromptFile via shell substitution alongside omitted -p", () => {
it("uses systemPromptFile via shell substitution with prompt after --", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ systemPromptFile: "/tmp/prompt.md", prompt: "Do the task" }),
);
expect(cmd).toContain('--append-system-prompt "$(cat');
expect(cmd).toContain("/tmp/prompt.md");
expect(cmd).not.toMatch(/\s-p\s/);
expect(cmd).not.toContain("Do the task");
expect(cmd).toContain("-- 'Do the task'");
});
});
@ -870,14 +873,17 @@ describe("hook setup — relative path (symlink-safe)", () => {
expect(hookCommand).not.toMatch(/^\//);
});
it("postLaunchSetup writes a relative hook command (not absolute)", async () => {
it("postLaunchSetup is a no-op (hooks installed pre-launch via setupWorkspaceHooks)", async () => {
mockWriteFile.mockClear();
await agent.postLaunchSetup!(
makeSession({ workspacePath: "/Users/equinox/.worktrees/integrator/integrator-10" }),
);
const hookCommand = getWrittenHookCommand();
expect(hookCommand).toBe(".claude/metadata-updater.sh");
expect(hookCommand).not.toMatch(/^\//);
// No files should be written — hooks are installed before launch
const settingsWrites = mockWriteFile.mock.calls.filter(
([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"),
);
expect(settingsWrites).toHaveLength(0);
});
it("different worktree paths produce identical settings.json content", async () => {

View File

@ -683,8 +683,6 @@ function createClaudeCodeAgent(): Agent {
return {
name: "claude-code",
processName: "claude",
promptDelivery: "post-launch",
getLaunchCommand(config: AgentLaunchConfig): string {
// Note: CLAUDECODE is unset via getEnvironment() (set to ""), not here.
// This command must be safe for both shell and execFile contexts.
@ -708,9 +706,12 @@ function createClaudeCodeAgent(): Agent {
parts.push("--append-system-prompt", shellEscape(config.systemPrompt));
}
// NOTE: prompt is NOT included here — it's delivered post-launch via
// runtime.sendMessage() to keep Claude in interactive mode.
// Using -p causes one-shot mode (Claude exits after responding).
// The positional [prompt] argument auto-submits as the first user turn
// and keeps Claude in interactive mode. -p / --print is what triggers
// headless one-shot exit, not the presence of a prompt.
if (config.prompt) {
parts.push("--", shellEscape(config.prompt));
}
return parts.join(" ");
},
@ -876,10 +877,9 @@ function createClaudeCodeAgent(): Agent {
await setupHookInWorkspace(workspacePath, ".claude/metadata-updater.sh");
},
async postLaunchSetup(session: Session): Promise<void> {
if (!session.workspacePath) return;
await setupHookInWorkspace(session.workspacePath, ".claude/metadata-updater.sh");
async postLaunchSetup(_session: Session): Promise<void> {
// Hooks are installed pre-launch via setupWorkspaceHooks so that
// PostToolUse hooks exist before the agent's first tool call.
},
};
}

View File

@ -278,7 +278,7 @@ function createGitHubTracker(): Tracker {
lines.push(
"",
"The issue context above is complete and current. You should not need to call gh issue view unless you need additional context beyond what is provided here.",
"The issue title, description, and labels above are current. Fetch comments or linked issues via `gh` only if you need additional context beyond what is provided here.",
"",
"Please implement the changes described in this issue. When done, commit and push your changes.",
);