feat: first-class orchestrator session + file-based system prompt (#101)

* feat: first-class orchestrator session + file-based system prompt

Make the orchestrator a first-class managed session that flows through
the same SessionManager pipeline as worker sessions, and fix a blocking
bug where long system prompts get truncated by tmux/zsh.

Changes:
- Add OrchestratorSpawnConfig type and spawnOrchestrator() to
  SessionManager interface
- Implement spawnOrchestrator() in session-manager.ts: proper
  hash-based tmuxName, runtimeHandle, plugin lifecycle — no workspace
  creation (uses project.path directly)
- Refactor `ao start` to use SessionManager.spawnOrchestrator()
  instead of manual tmux calls + metadata writes (~80 lines removed)
- Refactor `ao stop` to use SessionManager.kill() instead of manual
  tmux kill + metadata delete
- Update `ao init` next steps: guide users to `ao start` before
  `ao spawn`
- Add systemPromptFile to AgentLaunchConfig for file-based system
  prompts (avoids tmux truncation of 2000+ char inline prompts)
- Update agent-claude-code, agent-codex, agent-aider plugins to use
  shell command substitution "$(cat '/path')" when systemPromptFile
  is set
- Update runtime-tmux create() to use load-buffer/paste-buffer for
  launch commands >200 chars
- Add 8 tests for spawnOrchestrator
- Fix SessionManager mock in 8 test files (add spawnOrchestrator)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use hash-based tmux name in orchestrator attach hint

The tmux attach hint after `ao start` printed the user-facing session
ID (e.g. app-orchestrator) instead of the hash-based tmux session name
(e.g. a3b4c5d6e7f8-app-orchestrator), causing "session not found"
errors. Now captures the runtimeHandle.id from spawnOrchestrator's
return value for the correct tmux target.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prateek 2026-02-18 19:32:35 +05:30 committed by GitHub
parent 767558fed6
commit de6653e258
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 350 additions and 123 deletions

View File

@ -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: "" },

View File

@ -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: "" },

View File

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

View File

@ -37,6 +37,7 @@ const {
cleanup: vi.fn(),
get: vi.fn(),
spawn: vi.fn(),
spawnOrchestrator: vi.fn(),
send: vi.fn(),
},
sessionsDirRef: { current: "" },

View File

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

View File

@ -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<string, string> = {
[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`));
}

View File

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

View File

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

View File

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

View File

@ -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<Session> {
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<Session[]> {
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 };
}

View File

@ -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<Session>;
spawnOrchestrator(config: OrchestratorSpawnConfig): Promise<Session>;
list(projectId?: string): Promise<Session[]>;
get(sessionId: SessionId): Promise<Session | null>;
kill(sessionId: SessionId): Promise<void>;

View File

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

View File

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

View File

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

View File

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

View File

@ -81,6 +81,7 @@ const mockSessionManager: SessionManager = {
}
}),
cleanup: vi.fn(async () => ({ killed: [], skipped: [], errors: [] })),
spawnOrchestrator: vi.fn(),
};
const mockSCM: SCM = {