diff --git a/packages/cli/__tests__/commands/review-check.test.ts b/packages/cli/__tests__/commands/review-check.test.ts index 3cd28d25d..13a5843b5 100644 --- a/packages/cli/__tests__/commands/review-check.test.ts +++ b/packages/cli/__tests__/commands/review-check.test.ts @@ -16,6 +16,7 @@ const { mockTmux, mockExec, mockGh, mockConfigRef, mockSessionManager, sessionsD cleanup: vi.fn(), get: vi.fn(), spawn: vi.fn(), + spawnOrchestrator: vi.fn(), send: vi.fn(), }, sessionsDirRef: { current: "" }, diff --git a/packages/cli/__tests__/commands/session.test.ts b/packages/cli/__tests__/commands/session.test.ts index 8c3cbac51..ba44da863 100644 --- a/packages/cli/__tests__/commands/session.test.ts +++ b/packages/cli/__tests__/commands/session.test.ts @@ -31,6 +31,7 @@ const { mockTmux, mockGit, mockGh, mockExec, mockConfigRef, mockSessionManager, cleanup: vi.fn(), get: vi.fn(), spawn: vi.fn(), + spawnOrchestrator: vi.fn(), send: vi.fn(), }, sessionsDirRef: { current: "" }, diff --git a/packages/cli/__tests__/commands/spawn.test.ts b/packages/cli/__tests__/commands/spawn.test.ts index a4abc1824..594f2de86 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -13,6 +13,7 @@ const { mockExec, mockConfigRef, mockSessionManager } = vi.hoisted(() => ({ cleanup: vi.fn(), get: vi.fn(), spawn: vi.fn(), + spawnOrchestrator: vi.fn(), send: vi.fn(), }, })); diff --git a/packages/cli/__tests__/commands/status.test.ts b/packages/cli/__tests__/commands/status.test.ts index 57f0f9787..e3e77eb37 100644 --- a/packages/cli/__tests__/commands/status.test.ts +++ b/packages/cli/__tests__/commands/status.test.ts @@ -37,6 +37,7 @@ const { cleanup: vi.fn(), get: vi.fn(), spawn: vi.fn(), + spawnOrchestrator: vi.fn(), send: vi.fn(), }, sessionsDirRef: { current: "" }, diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 92c5fac80..b009c9f5f 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -346,21 +346,19 @@ export function registerInit(program: Command): void { // Success message and next steps console.log(chalk.green(`\n✓ Config written to ${outputPath}\n`)); console.log(chalk.bold("Next steps:\n")); - console.log(" 1. Review and edit the config:"); + console.log(" 1. Review the config (optional):"); console.log(chalk.cyan(` nano ${outputPath}\n`)); + console.log(" 2. Start orchestrator + dashboard:"); + console.log(chalk.cyan(" ao start\n")); if (projectId) { - console.log(" 2. Spawn your first agent:"); + console.log(" 3. Spawn agent sessions:"); console.log(chalk.cyan(` ao spawn ${projectId} ISSUE-123\n`)); } else { - console.log(" 2. Add a project to the config:"); + console.log(" 3. Add a project to the config:"); console.log(chalk.cyan(` nano ${outputPath}\n`)); } - console.log(" 3. Monitor progress:"); - console.log(chalk.cyan(" ao status\n")); - console.log(" 4. Open dashboard:"); - console.log(chalk.cyan(" ao start\n")); console.log(chalk.dim("See SETUP.md for detailed configuration options.\n")); if (!env.hasTmux) { @@ -481,17 +479,17 @@ async function handleAutoMode(outputPath: string, smart: boolean): Promise if (hasPlaceholderRepo) { console.log(" 1. Edit config and update 'repo' field:"); console.log(chalk.cyan(` nano ${outputPath}\n`)); - console.log(" 2. Spawn your first agent:"); + console.log(" 2. Start orchestrator + dashboard:"); + console.log(chalk.cyan(" ao start\n")); + console.log(" 3. Spawn agent sessions:"); console.log(chalk.cyan(` ao spawn ${projectId} ISSUE-123\n`)); - console.log(" 3. Monitor progress:"); - console.log(chalk.cyan(" ao status\n")); } else { - console.log(" 1. Review and customize (optional):"); + console.log(" 1. Review the config (optional):"); console.log(chalk.cyan(` nano ${outputPath}\n`)); - console.log(" 2. Spawn your first agent:"); + console.log(" 2. Start orchestrator + dashboard:"); + console.log(chalk.cyan(" ao start\n")); + console.log(" 3. Spawn agent sessions:"); console.log(chalk.cyan(` ao spawn ${projectId} ISSUE-123\n`)); - console.log(" 3. Monitor progress:"); - console.log(chalk.cyan(" ao status\n")); } // Show warnings diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 745ba0295..9d4d7ef6a 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -15,17 +15,11 @@ import type { Command } from "commander"; import { loadConfig, generateOrchestratorPrompt, - hasTmuxSession, - newTmuxSession, - tmuxSendKeys, - writeMetadata, - deleteMetadata, - getSessionsDir, type OrchestratorConfig, type ProjectConfig, } from "@composio/ao-core"; -import { exec, getTmuxSessions } from "../lib/shell.js"; -import { getAgent } from "../lib/plugins.js"; +import { exec } from "../lib/shell.js"; +import { getSessionManager } from "../lib/create-session-manager.js"; import { findWebDir, buildDashboardEnv } from "../lib/web-dir.js"; import { cleanNextCache } from "../lib/dashboard-rebuild.js"; @@ -161,12 +155,19 @@ export function registerStart(program: Command): void { console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n")); } - // Create orchestrator tmux session (unless --no-orchestrator or already exists) + // Create orchestrator session (unless --no-orchestrator or already exists) + let tmuxTarget = sessionId; // For the attach hint — updated to hash-based name after spawn if (opts?.orchestrator !== false) { + const sm = await getSessionManager(config); + // Check if orchestrator session already exists - exists = await hasTmuxSession(sessionId); + const existing = await sm.get(sessionId); + exists = existing !== null && existing.status !== "killed"; if (exists) { + if (existing?.runtimeHandle?.id) { + tmuxTarget = existing.runtimeHandle.id; + } console.log( chalk.yellow( `Orchestrator session "${sessionId}" is already running (skipping creation)`, @@ -174,90 +175,14 @@ export function registerStart(program: Command): void { ); } else { try { - // Get agent instance (used for hooks and launch) - const agent = getAgent(config, projectId); - const sessionsDir = getSessionsDir(config.configPath, project.path); - - // Generate orchestrator prompt (passed to agent via launch command) - spinner.start("Generating orchestrator prompt"); - const systemPrompt = generateOrchestratorPrompt({ config, projectId, project }); - spinner.succeed("Orchestrator prompt ready"); - - // Setup agent hooks for automatic metadata updates - spinner.start("Configuring agent hooks"); - if (agent.setupWorkspaceHooks) { - await agent.setupWorkspaceHooks(project.path, { dataDir: sessionsDir }); - } - spinner.succeed("Agent hooks configured"); - spinner.start("Creating orchestrator session"); + const systemPrompt = generateOrchestratorPrompt({ config, projectId, project }); - // Get agent launch command (includes system prompt) - const launchCmd = agent.getLaunchCommand({ - sessionId, - projectConfig: project, - permissions: project.agentConfig?.permissions ?? "default", - model: project.agentConfig?.model, - systemPrompt, - }); - - // Determine environment variables - const envVarName = `${project.sessionPrefix.toUpperCase().replace(/[^A-Z0-9_]/g, "_")}_SESSION`; - const environment: Record = { - [envVarName]: sessionId, - AO_SESSION: sessionId, - AO_DATA_DIR: sessionsDir, - DIRENV_LOG_FORMAT: "", - }; - - // Merge agent-specific environment - const agentEnv = agent.getEnvironment({ - sessionId, - projectConfig: project, - permissions: project.agentConfig?.permissions ?? "default", - model: project.agentConfig?.model, - }); - Object.assign(environment, agentEnv); - - // NOTE: AO_PROJECT_ID is intentionally not set for orchestrator (uses flat metadata path) - - // Create tmux session - await newTmuxSession({ - name: sessionId, - cwd: project.path, - environment, - }); - - try { - // Launch agent - await tmuxSendKeys(sessionId, launchCmd, true); - - spinner.succeed("Orchestrator session created"); - - // Write metadata - const runtimeHandle = JSON.stringify({ - id: sessionId, - runtimeName: "tmux", - data: {}, - }); - - writeMetadata(sessionsDir, sessionId, { - worktree: project.path, - branch: project.defaultBranch, - status: "working", - project: projectId, - createdAt: new Date().toISOString(), - runtimeHandle, - }); - } catch (err) { - // Cleanup tmux session if metadata write or agent launch fails - try { - await exec("tmux", ["kill-session", "-t", sessionId]); - } catch { - // Best effort cleanup - session may not exist - } - throw err; + const session = await sm.spawnOrchestrator({ projectId, systemPrompt }); + if (session.runtimeHandle?.id) { + tmuxTarget = session.runtimeHandle.id; } + spinner.succeed("Orchestrator session created"); } catch (err) { spinner.fail("Orchestrator setup failed"); // Cleanup dashboard if orchestrator setup fails @@ -280,7 +205,7 @@ export function registerStart(program: Command): void { } if (opts?.orchestrator !== false && !exists) { - console.log(chalk.cyan("Orchestrator:"), `tmux attach -t ${sessionId}`); + console.log(chalk.cyan("Orchestrator:"), `tmux attach -t ${tmuxTarget}`); } else if (exists) { console.log(chalk.cyan("Orchestrator:"), `already running (${sessionId})`); } @@ -323,19 +248,17 @@ export function registerStop(program: Command): void { const { projectId: _projectId, project } = resolveProject(config, projectArg); const sessionId = `${project.sessionPrefix}-orchestrator`; const port = config.port ?? 3000; - const sessionsDir = getSessionsDir(config.configPath, project.path); console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`)); - // Kill orchestrator session - const sessions = await getTmuxSessions(); - if (sessions.includes(sessionId)) { - const spinner = ora("Stopping orchestrator session").start(); - await exec("tmux", ["kill-session", "-t", sessionId]); - spinner.succeed("Orchestrator session stopped"); + // Kill orchestrator session via SessionManager + const sm = await getSessionManager(config); + const existing = await sm.get(sessionId); - // Archive metadata - deleteMetadata(sessionsDir, sessionId, true); + if (existing) { + const spinner = ora("Stopping orchestrator session").start(); + await sm.kill(sessionId); + spinner.succeed("Orchestrator session stopped"); } else { console.log(chalk.yellow(`Orchestrator session "${sessionId}" is not running`)); } diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 2663ab4b2..daecf853b 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -103,6 +103,7 @@ beforeEach(() => { mockSessionManager = { spawn: vi.fn(), + spawnOrchestrator: vi.fn(), list: vi.fn().mockResolvedValue([]), get: vi.fn().mockResolvedValue(null), kill: vi.fn().mockResolvedValue(undefined), diff --git a/packages/core/src/__tests__/plugin-integration.test.ts b/packages/core/src/__tests__/plugin-integration.test.ts index 2e4483a5d..209883fc2 100644 --- a/packages/core/src/__tests__/plugin-integration.test.ts +++ b/packages/core/src/__tests__/plugin-integration.test.ts @@ -444,6 +444,7 @@ describe("plugin integration", () => { get: vi.fn().mockResolvedValue(makeSession({ status: "pr_open", pr })), kill: vi.fn().mockResolvedValue(undefined), send: vi.fn().mockResolvedValue(undefined), + spawnOrchestrator: vi.fn(), }; const lm = createLifecycleManager({ @@ -473,6 +474,7 @@ describe("plugin integration", () => { get: vi.fn().mockResolvedValue(makeSession({ status: "pr_open", pr })), kill: vi.fn().mockResolvedValue(undefined), send: vi.fn().mockResolvedValue(undefined), + spawnOrchestrator: vi.fn(), }; const lm = createLifecycleManager({ @@ -499,6 +501,7 @@ describe("plugin integration", () => { get: vi.fn().mockResolvedValue(makeSession({ status: "pr_open", pr })), kill: vi.fn().mockResolvedValue(undefined), send: vi.fn().mockResolvedValue(undefined), + spawnOrchestrator: vi.fn(), }; const lm = createLifecycleManager({ diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 6bf1705c3..801b54af5 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -749,6 +749,132 @@ describe("send", () => { }); }); +describe("spawnOrchestrator", () => { + it("creates orchestrator session with correct ID", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + const session = await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(session.id).toBe("app-orchestrator"); + expect(session.status).toBe("working"); + expect(session.projectId).toBe("my-app"); + expect(session.branch).toBe("main"); + expect(session.issueId).toBeNull(); + expect(session.workspacePath).toBe(join(tmpDir, "my-app")); + }); + + it("writes metadata with proper fields", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + await sm.spawnOrchestrator({ projectId: "my-app" }); + + const meta = readMetadata(sessionsDir, "app-orchestrator"); + expect(meta).not.toBeNull(); + expect(meta!.status).toBe("working"); + expect(meta!.project).toBe("my-app"); + expect(meta!.worktree).toBe(join(tmpDir, "my-app")); + expect(meta!.branch).toBe("main"); + expect(meta!.tmuxName).toBeDefined(); + expect(meta!.runtimeHandle).toBeDefined(); + }); + + it("skips workspace creation", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(mockWorkspace.create).not.toHaveBeenCalled(); + }); + + it("calls agent.setupWorkspaceHooks on project path", async () => { + const agentWithHooks: Agent = { + ...mockAgent, + setupWorkspaceHooks: vi.fn().mockResolvedValue(undefined), + }; + const registryWithHooks: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return agentWithHooks; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + const sm = createSessionManager({ config, registry: registryWithHooks }); + await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(agentWithHooks.setupWorkspaceHooks).toHaveBeenCalledWith( + join(tmpDir, "my-app"), + expect.objectContaining({ dataDir: sessionsDir }), + ); + }); + + it("calls runtime.create with proper config", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(mockRuntime.create).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePath: join(tmpDir, "my-app"), + launchCommand: "mock-agent --start", + }), + ); + }); + + it("writes system prompt to file and passes systemPromptFile to agent", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + await sm.spawnOrchestrator({ + projectId: "my-app", + systemPrompt: "You are the orchestrator.", + }); + + // Should pass systemPromptFile (not inline systemPrompt) to avoid tmux truncation + expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "app-orchestrator", + systemPromptFile: expect.stringContaining("orchestrator-prompt.md"), + }), + ); + + // Verify the file was actually written + const callArgs = vi.mocked(mockAgent.getLaunchCommand).mock.calls[0][0]; + const promptFile = callArgs.systemPromptFile!; + expect(existsSync(promptFile)).toBe(true); + const { readFileSync } = await import("node:fs"); + expect(readFileSync(promptFile, "utf-8")).toBe("You are the orchestrator."); + }); + + it("throws for unknown project", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + await expect(sm.spawnOrchestrator({ projectId: "nonexistent" })).rejects.toThrow( + "Unknown project", + ); + }); + + it("throws when runtime plugin is missing", async () => { + const emptyRegistry: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockReturnValue(null), + }; + + const sm = createSessionManager({ config, registry: emptyRegistry }); + + await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow("not found"); + }); + + it("returns session with runtimeHandle", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + const session = await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(session.runtimeHandle).toEqual(makeHandle("rt-1")); + }); +}); + describe("PluginRegistry.loadBuiltins importFn", () => { it("should use provided importFn instead of built-in import", async () => { const { createPluginRegistry: createReg } = await import("../plugin-registry.js"); diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 4efeaf741..599787d5c 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -11,7 +11,7 @@ * Reference: scripts/claude-ao-session, scripts/send-to-session */ -import { statSync, existsSync, readdirSync } from "node:fs"; +import { statSync, existsSync, readdirSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { isIssueNotFoundError, @@ -19,6 +19,7 @@ import { type Session, type SessionId, type SessionSpawnConfig, + type OrchestratorSpawnConfig, type SessionStatus, type CleanupResult, type OrchestratorConfig, @@ -41,7 +42,7 @@ import { reserveSessionId, } from "./metadata.js"; import { buildPrompt } from "./prompt-builder.js"; -import { getSessionsDir, generateTmuxName, validateAndStoreOrigin } from "./paths.js"; +import { getSessionsDir, getProjectBaseDir, generateTmuxName, generateConfigHash, validateAndStoreOrigin } from "./paths.js"; /** Escape regex metacharacters in a string. */ function escapeRegex(str: string): string { @@ -514,6 +515,127 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { return session; } + async function spawnOrchestrator(orchestratorConfig: OrchestratorSpawnConfig): Promise { + const project = config.projects[orchestratorConfig.projectId]; + if (!project) { + throw new Error(`Unknown project: ${orchestratorConfig.projectId}`); + } + + const plugins = resolvePlugins(project); + if (!plugins.runtime) { + throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`); + } + if (!plugins.agent) { + throw new Error(`Agent plugin '${project.agent ?? config.defaults.agent}' not found`); + } + + const sessionId = `${project.sessionPrefix}-orchestrator`; + + // Generate tmux name if using new architecture + let tmuxName: string | undefined; + if (config.configPath) { + const hash = generateConfigHash(config.configPath); + tmuxName = `${hash}-${sessionId}`; + } + + // Get the sessions directory for this project + const sessionsDir = getProjectSessionsDir(project); + + // Validate and store .origin file + if (config.configPath) { + validateAndStoreOrigin(config.configPath, project.path); + } + + // Setup agent hooks for automatic metadata updates + if (plugins.agent.setupWorkspaceHooks) { + await plugins.agent.setupWorkspaceHooks(project.path, { dataDir: sessionsDir }); + } + + // Write system prompt to a file to avoid shell/tmux truncation. + // Long prompts (2000+ chars) get mangled when inlined in shell commands + // via tmux send-keys or paste-buffer. File-based approach is reliable. + let systemPromptFile: string | undefined; + if (orchestratorConfig.systemPrompt) { + const baseDir = getProjectBaseDir(config.configPath, project.path); + mkdirSync(baseDir, { recursive: true }); + systemPromptFile = join(baseDir, "orchestrator-prompt.md"); + writeFileSync(systemPromptFile, orchestratorConfig.systemPrompt, "utf-8"); + } + + // Get agent launch config — uses systemPromptFile, no issue/tracker interaction + const agentLaunchConfig = { + sessionId, + projectConfig: project, + permissions: project.agentConfig?.permissions, + model: project.agentConfig?.model, + systemPromptFile, + }; + + const launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig); + const environment = plugins.agent.getEnvironment(agentLaunchConfig); + + const handle = await plugins.runtime.create({ + sessionId: tmuxName ?? sessionId, + workspacePath: project.path, + launchCommand, + environment: { + ...environment, + AO_SESSION: sessionId, + AO_DATA_DIR: sessionsDir, + AO_SESSION_NAME: sessionId, + ...(tmuxName && { AO_TMUX_NAME: tmuxName }), + }, + }); + + // Write metadata and run post-launch setup + const session: Session = { + id: sessionId, + projectId: orchestratorConfig.projectId, + status: "working", + activity: "active", + branch: project.defaultBranch, + issueId: null, + pr: null, + workspacePath: project.path, + runtimeHandle: handle, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + }; + + try { + writeMetadata(sessionsDir, sessionId, { + worktree: project.path, + branch: project.defaultBranch, + status: "working", + tmuxName, + project: orchestratorConfig.projectId, + createdAt: new Date().toISOString(), + runtimeHandle: JSON.stringify(handle), + }); + + if (plugins.agent.postLaunchSetup) { + await plugins.agent.postLaunchSetup(session); + } + } catch (err) { + // Clean up runtime on post-launch failure + try { + await plugins.runtime.destroy(handle); + } catch { + /* best effort */ + } + try { + deleteMetadata(sessionsDir, sessionId, false); + } catch { + /* best effort */ + } + throw err; + } + + return session; + } + async function list(projectId?: string): Promise { const allSessions = listAllSessions(projectId); const sessions: Session[] = []; @@ -747,5 +869,5 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { await runtimePlugin.sendMessage(handle, message); } - return { spawn, list, get, kill, cleanup, send }; + return { spawn, spawnOrchestrator, list, get, kill, cleanup, send }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2ccf9d1fd..6de8565a4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -133,6 +133,12 @@ export interface SessionSpawnConfig { prompt?: string; } +/** Config for creating an orchestrator session */ +export interface OrchestratorSpawnConfig { + projectId: string; + systemPrompt?: string; +} + // ============================================================================= // RUNTIME — Plugin Slot 1 // ============================================================================= @@ -269,8 +275,21 @@ export interface AgentLaunchConfig { * - Codex: --system-prompt or AGENTS.md * - Aider: --system-prompt flag * - OpenCode: equivalent mechanism + * + * For short prompts only. For long prompts, use systemPromptFile instead + * to avoid shell/tmux truncation issues. */ systemPrompt?: string; + /** + * Path to a file containing the system prompt. + * Preferred over systemPrompt for long prompts (e.g. orchestrator prompts) + * because inlining 2000+ char prompts in shell commands causes truncation. + * + * When set, takes precedence over systemPrompt. + * - Claude Code: --append-system-prompt "$(cat /path/to/file)" + * - Codex/Aider: similar shell substitution + */ + systemPromptFile?: string; } export interface WorkspaceHooksConfig { @@ -897,6 +916,7 @@ export interface SessionMetadata { /** Session manager — CRUD for sessions */ export interface SessionManager { spawn(config: SessionSpawnConfig): Promise; + spawnOrchestrator(config: OrchestratorSpawnConfig): Promise; list(projectId?: string): Promise; get(sessionId: SessionId): Promise; kill(sessionId: SessionId): Promise; diff --git a/packages/plugins/agent-aider/src/index.ts b/packages/plugins/agent-aider/src/index.ts index 4e96a33ac..00d532a16 100644 --- a/packages/plugins/agent-aider/src/index.ts +++ b/packages/plugins/agent-aider/src/index.ts @@ -82,7 +82,9 @@ function createAiderAgent(): Agent { parts.push("--model", shellEscape(config.model)); } - if (config.systemPrompt) { + if (config.systemPromptFile) { + parts.push("--system-prompt", `"$(cat ${shellEscape(config.systemPromptFile)})"`); + } else if (config.systemPrompt) { parts.push("--system-prompt", shellEscape(config.systemPrompt)); } diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 86571c440..7be873dd7 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -562,7 +562,12 @@ function createClaudeCodeAgent(): Agent { parts.push("--model", shellEscape(config.model)); } - if (config.systemPrompt) { + if (config.systemPromptFile) { + // Use shell command substitution to read from file at launch time. + // This avoids tmux truncation when inlining 2000+ char prompts. + // The double quotes allow $() expansion; inner path is single-quoted for safety. + parts.push("--append-system-prompt", `"$(cat ${shellEscape(config.systemPromptFile)})"`); + } else if (config.systemPrompt) { parts.push("--append-system-prompt", shellEscape(config.systemPrompt)); } diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index 2d94a9ea7..03f1f028f 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -44,7 +44,9 @@ function createCodexAgent(): Agent { parts.push("--model", shellEscape(config.model)); } - if (config.systemPrompt) { + if (config.systemPromptFile) { + parts.push("--system-prompt", `"$(cat ${shellEscape(config.systemPromptFile)})"`); + } else if (config.systemPrompt) { parts.push("--system-prompt", shellEscape(config.systemPrompt)); } diff --git a/packages/plugins/runtime-tmux/src/index.ts b/packages/plugins/runtime-tmux/src/index.ts index 19c51cdad..8475289d9 100644 --- a/packages/plugins/runtime-tmux/src/index.ts +++ b/packages/plugins/runtime-tmux/src/index.ts @@ -55,9 +55,29 @@ export function create(): Runtime { // Create tmux session in detached mode await tmux("new-session", "-d", "-s", sessionName, "-c", config.workspacePath, ...envArgs); - // Send the launch command — clean up the session if this fails + // Send the launch command — clean up the session if this fails. + // Use load-buffer + paste-buffer for long commands to avoid tmux/zsh + // truncation issues (commands >200 chars get mangled by send-keys). try { - await tmux("send-keys", "-t", sessionName, config.launchCommand, "Enter"); + if (config.launchCommand.length > 200) { + const bufferName = `ao-launch-${randomUUID().slice(0, 8)}`; + const tmpPath = join(tmpdir(), `ao-launch-${randomUUID()}.txt`); + writeFileSync(tmpPath, config.launchCommand, { encoding: "utf-8", mode: 0o600 }); + try { + await tmux("load-buffer", "-b", bufferName, tmpPath); + await tmux("paste-buffer", "-b", bufferName, "-t", sessionName, "-d"); + } finally { + try { + unlinkSync(tmpPath); + } catch { + /* ignore cleanup errors */ + } + } + await sleep(300); + await tmux("send-keys", "-t", sessionName, "Enter"); + } else { + await tmux("send-keys", "-t", sessionName, config.launchCommand, "Enter"); + } } catch (err: unknown) { try { await tmux("kill-session", "-t", sessionName); diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 4ba7025c9..d306cad99 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -81,6 +81,7 @@ const mockSessionManager: SessionManager = { } }), cleanup: vi.fn(async () => ({ killed: [], skipped: [], errors: [] })), + spawnOrchestrator: vi.fn(), }; const mockSCM: SCM = {