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:
parent
767558fed6
commit
de6653e258
|
|
@ -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: "" },
|
||||
|
|
|
|||
|
|
@ -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: "" },
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ const {
|
|||
cleanup: vi.fn(),
|
||||
get: vi.fn(),
|
||||
spawn: vi.fn(),
|
||||
spawnOrchestrator: vi.fn(),
|
||||
send: vi.fn(),
|
||||
},
|
||||
sessionsDirRef: { current: "" },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ const mockSessionManager: SessionManager = {
|
|||
}
|
||||
}),
|
||||
cleanup: vi.fn(async () => ({ killed: [], skipped: [], errors: [] })),
|
||||
spawnOrchestrator: vi.fn(),
|
||||
};
|
||||
|
||||
const mockSCM: SCM = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue