feat: implement ao start command for unified orchestrator startup (#42)
* feat: implement ao start command for unified orchestrator startup
Adds `ao start` and `ao stop` commands to unify orchestrator and
dashboard startup. Key features:
- Generates CLAUDE.orchestrator.md with project-specific context
- Auto-imports orchestrator prompt via CLAUDE.local.md
- Creates orchestrator tmux session with agent
- Starts Next.js dashboard server
- Supports --no-dashboard, --no-orchestrator, --regenerate flags
- Idempotent operation (safe to run multiple times)
- Computes orchestrator ID from config (not session search)
- Dashboard button always visible for orchestrator terminal
Components:
- packages/core/src/orchestrator-prompt.ts: Prompt generator
- packages/cli/src/commands/start.ts: Start/stop commands
- Modified exports and dashboard UI for orchestrator support
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* feat: add automatic metadata updates via Claude Code hooks
CRITICAL: This makes the dashboard work by auto-updating metadata when
agents run git/gh commands. Without this, PRs created by agents never
appear on the dashboard.
Changes:
- packages/core/src/claude-hooks.ts: Setup Claude hooks (settings.json + metadata-updater.sh)
- ao start: Automatically configures Claude hooks in project directory
- ao spawn: Sets AO_SESSION and AO_DATA_DIR env vars for hook script
- metadata-updater.sh: Detects gh pr create, git checkout -b, gh pr merge
How it works:
1. PostToolUse hook fires after every Bash command
2. metadata-updater.sh receives JSON with command and output
3. Pattern matches git/gh commands and updates flat metadata files
4. Dashboard reads metadata files to show PR/branch/status
The .claude directory is symlinked from main repo to worktrees so all
sessions share the same hook config.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* refactor: move hook setup to Agent plugin interface
ARCHITECTURE: Hooks setup must go through the Agent plugin interface,
not be hardcoded for Claude Code. This allows other agents (Codex,
Aider, OpenCode) to implement their own metadata update mechanisms.
Changes:
- Added Agent.setupWorkspaceHooks() method to types.ts
- Added WorkspaceHooksConfig interface
- Implemented setupWorkspaceHooks() in Claude Code plugin
- ao start: calls agent.setupWorkspaceHooks() instead of direct setup
- ao spawn: calls agent.setupWorkspaceHooks() for new worktrees
- Uses $CLAUDE_PROJECT_DIR variable for hook path (works with symlinked .claude)
Each agent plugin now implements its own hook mechanism:
- Claude Code: .claude/settings.json with PostToolUse hook
- Future: Codex, Aider, OpenCode with their own config formats
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: address Cursor Bugbot review comments
Fixes 3 issues identified by Cursor Bugbot:
1. HIGH: ao start is now truly idempotent - if orchestrator session exists,
it skips creating the session but still proceeds with dashboard startup
and hook configuration. This allows `ao start` to recover from dashboard
crashes without failing.
2. MEDIUM: Dashboard orchestrator button now finds the actual running
orchestrator session instead of always using the first project. Fallback
to first project ID if no orchestrator is running.
3. LOW: Deduplicated findWebDir() function by moving it to shared utility
lib/web-dir.ts. Now used by both dashboard.ts and start.ts.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: resolve ESLint errors
Fixes 4 ESLint errors identified in CI:
- Added { cause: err } to Error constructors (preserve-caught-error rule)
- Prefixed unused parameter 'config' with underscore in setupWorkspaceHooks
- Prefixed unused variable 'projectId' with underscore in stop command
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: address bugbot review comments - markdown escaping and unused module
- Fix markdown code fence escaping in orchestrator-prompt.ts (change
`\\\`` to `\`` for proper markdown rendering)
- Remove unused claude-hooks.ts module (functionality moved to plugin)
- Clean up exports from core/src/index.ts
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: address remaining bugbot issues - metadata path, duplication, summary
HIGH severity - Fix metadata path mismatch for worker sessions:
- Add AO_PROJECT_ID environment variable in spawn.ts
- Update metadata-updater hook script to construct correct path:
* Worker sessions: $AO_DATA_DIR/${AO_PROJECT_ID}-sessions/$AO_SESSION
* Orchestrator: $AO_DATA_DIR/$AO_SESSION (no project ID)
- Fixes silent hook failures where PRs/branches never appeared on dashboard
LOW severity - Remove code duplication in hook setup:
- Extract setupHookInWorkspace() helper function (90 lines)
- Refactor setupWorkspaceHooks() to use helper (from 80 lines to 4)
- Refactor postLaunchSetup() to use helper (from 82 lines to 6)
- Eliminates risk of methods drifting out of sync
LOW severity - Fix misleading summary output:
- Change "Orchestrator started" to "Startup complete" when components skipped
- Only show dashboard URL when --no-dashboard NOT used
- Only show session info when --no-orchestrator NOT used
- Show "already running" status when orchestrator exists
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: address new bugbot issues - hook quoting, orchestrator link, pkill scope
HIGH severity - Fix Claude hook command quoting:
- Remove embedded double quotes from $CLAUDE_PROJECT_DIR path
- Change from '"$CLAUDE_PROJECT_DIR"/.claude/...' to '$CLAUDE_PROJECT_DIR/.claude/...'
- Prevents hook execution failures if runner treats command as literal path
MEDIUM severity - Fix orchestrator link pointing to nowhere:
- Only show "orchestrator terminal" link when session actually exists
- Remove fallback to computed/hardcoded "ao-orchestrator" ID
- Prevents 404s when user clicks link before starting orchestrator
MEDIUM severity - Fix stop command killing unrelated processes:
- Replace broad `pkill -f "next dev -p ${port}"` with targeted approach
- Use `lsof -ti :${port}` to find exact PID listening on port
- Only kill the specific process, not any process mentioning "next dev"
- Prevents accidentally killing unrelated Next.js dev servers
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: orchestrator metadata path and multi-pid dashboard stop
HIGH severity - Fix orchestrator AO_PROJECT_ID pollution:
- Orchestrator intentionally omits AO_PROJECT_ID (uses flat metadata path)
- agent.getEnvironment() adds AO_PROJECT_ID=project.name
- Object.assign() merged this in, breaking metadata hook path lookup
- Fix: delete environment.AO_PROJECT_ID after merge
- Ensures orchestrator metadata updates work correctly
MEDIUM severity - Fix stopDashboard with multiple PIDs:
- lsof -ti :PORT returns multiple PIDs (one per line) for parent+children
- Passing entire multi-line string to kill fails (can't parse newlines)
- Fix: split stdout by newlines, filter empty, pass PIDs as separate args
- Now correctly stops dashboard even when multiple Node processes exist
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: remove AO_PROJECT_ID from agent plugins to fix metadata path mismatch
The agent.getEnvironment() method was setting AO_PROJECT_ID to
config.projectConfig.name, but different callers have different metadata
path schemes:
- spawn.ts writes to project-specific directories (dataDir/{projectId}-sessions/)
- start.ts writes to flat directories for orchestrator (dataDir/)
- session-manager writes to flat directories (dataDir/)
Setting AO_PROJECT_ID in getEnvironment() caused the metadata updater hook
to look for files in the wrong location for orchestrator and session-manager
flows, breaking automatic metadata updates.
Fix: Remove AO_PROJECT_ID from all agent plugins' getEnvironment() methods
and make it the caller's responsibility to set when using project-specific
directories. Only spawn.ts sets it now.
Changes:
- Remove AO_PROJECT_ID assignment from getEnvironment() in all 4 agent plugins
(claude-code, aider, codex, opencode)
- Update corresponding tests to expect AO_PROJECT_ID to be undefined
- Remove delete environment.AO_PROJECT_ID statement from start.ts (no longer needed)
- Add comments explaining the metadata path scheme contract
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: only run orchestrator setup when actually starting orchestrator
The orchestrator-specific setup steps (generating CLAUDE.orchestrator.md,
configuring CLAUDE.local.md, and setting up agent hooks) were executing
unconditionally, even when --no-orchestrator was passed. This caused
`ao start --no-dashboard` to fail if hook setup had errors, because the
hook setup was fatal and blocked the dashboard from starting.
Fix: Move all orchestrator setup steps inside the `if (opts?.orchestrator !== false)`
guard, and specifically inside the `else` branch (when session doesn't already exist).
Now these steps only run when we're actually creating a new orchestrator session.
This allows:
- `ao start --no-orchestrator` to start only the dashboard
- Skipping setup when orchestrator session already exists
- Setup to be non-blocking for dashboard-only mode
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: eliminate redundant getAgent call by hoisting agent declaration
The agent instance was being created twice in the orchestrator setup block:
- Once at line 237 inside the hook setup try block
- Again at line 253 for getting the launch command
This creates duplicate agent instances unnecessarily. Fixed by declaring
the agent variable before the hook setup try block, allowing it to be
reused for both hook setup and launch command generation.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: address resource leaks and undefined env variable
Fixed 4 Bugbot issues:
1. HIGH: Undefined $CLAUDE_PROJECT_DIR in hook script path
- Changed setupWorkspaceHooks to use absolute path instead of undefined
env variable
- Matches approach used in postLaunchSetup for consistency
2. HIGH: Orchestrator session leaks when metadata write fails
- Added try-catch around tmux session launch and metadata write
- Kills tmux session if metadata write or agent launch fails
- Prevents orphaned sessions from consuming resources
3. MEDIUM: Dashboard process leaks when orchestrator setup fails
- Wrapped orchestrator setup in try-catch block
- Kills dashboard child process if orchestrator setup fails
- Prevents orphaned Next.js server from blocking future starts
4. LOW: Inconsistent indentation (fixed as side effect of restructuring)
- Refactored error handling simplified indentation structure
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1cd7c519af
commit
77323cb309
|
|
@ -1,42 +1,10 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { resolve } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { loadConfig } from "@composio/ao-core";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Locate the @composio/ao-web package directory.
|
||||
* Uses createRequire for ESM-compatible require.resolve, with fallback
|
||||
* to sibling package paths that work from both src/ and dist/.
|
||||
*/
|
||||
function findWebDir(): string {
|
||||
// Try to resolve from node_modules first (installed as workspace dep)
|
||||
try {
|
||||
const pkgJson = require.resolve("@composio/ao-web/package.json");
|
||||
return resolve(pkgJson, "..");
|
||||
} catch {
|
||||
// Fallback: sibling package in monorepo (works both from src/ and dist/)
|
||||
// packages/cli/src/commands/ → packages/web
|
||||
// packages/cli/dist/commands/ → packages/web
|
||||
const candidates = [
|
||||
resolve(__dirname, "../../../web"),
|
||||
resolve(__dirname, "../../../../packages/web"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(resolve(candidate, "package.json"))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return candidates[0];
|
||||
}
|
||||
}
|
||||
import { findWebDir } from "../lib/web-dir.js";
|
||||
|
||||
export function registerDashboard(program: Command): void {
|
||||
program
|
||||
|
|
|
|||
|
|
@ -115,6 +115,22 @@ async function spawnSession(
|
|||
}
|
||||
}
|
||||
|
||||
// Get agent plugin (used for hooks and launch)
|
||||
const agent = getAgent(config, projectId);
|
||||
|
||||
// Setup agent hooks for automatic metadata updates (before agent launch)
|
||||
spinner.text = "Configuring agent hooks";
|
||||
if (agent.setupWorkspaceHooks) {
|
||||
try {
|
||||
await agent.setupWorkspaceHooks(worktreePath, {
|
||||
dataDir: config.dataDir,
|
||||
sessionId: sessionName,
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal — continue even if hook setup fails
|
||||
}
|
||||
}
|
||||
|
||||
spinner.text = "Creating tmux session";
|
||||
|
||||
// Create tmux session
|
||||
|
|
@ -129,6 +145,12 @@ async function spawnSession(
|
|||
"-e",
|
||||
`${envVar}=${sessionName}`,
|
||||
"-e",
|
||||
`AO_SESSION=${sessionName}`,
|
||||
"-e",
|
||||
`AO_PROJECT_ID=${projectId}`,
|
||||
"-e",
|
||||
`AO_DATA_DIR=${config.dataDir}`,
|
||||
"-e",
|
||||
"DIRENV_LOG_FORMAT=",
|
||||
]);
|
||||
|
||||
|
|
@ -143,7 +165,6 @@ async function spawnSession(
|
|||
}
|
||||
|
||||
// Start agent via plugin
|
||||
const agent = getAgent(config, projectId);
|
||||
const launchCmd = agent.getLaunchCommand({
|
||||
sessionId: sessionName,
|
||||
projectConfig: project,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,398 @@
|
|||
/**
|
||||
* `ao start` and `ao stop` commands — unified orchestrator startup.
|
||||
*
|
||||
* Starts both the dashboard and the orchestrator agent session, generating
|
||||
* CLAUDE.orchestrator.md and injecting it via CLAUDE.local.md import.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { resolve, join } from "node:path";
|
||||
import chalk from "chalk";
|
||||
import ora from "ora";
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
loadConfig,
|
||||
generateOrchestratorPrompt,
|
||||
hasTmuxSession,
|
||||
newTmuxSession,
|
||||
tmuxSendKeys,
|
||||
writeMetadata,
|
||||
deleteMetadata,
|
||||
type OrchestratorConfig,
|
||||
type ProjectConfig,
|
||||
} from "@composio/ao-core";
|
||||
import { exec, getTmuxSessions } from "../lib/shell.js";
|
||||
import { getAgent } from "../lib/plugins.js";
|
||||
import { findWebDir } from "../lib/web-dir.js";
|
||||
|
||||
/**
|
||||
* Ensure CLAUDE.orchestrator.md exists in the project directory.
|
||||
* Generate it if missing or if --regenerate flag is set.
|
||||
*/
|
||||
function ensureOrchestratorPrompt(
|
||||
projectPath: string,
|
||||
config: OrchestratorConfig,
|
||||
projectId: string,
|
||||
project: ProjectConfig,
|
||||
regenerate = false,
|
||||
): void {
|
||||
const promptPath = join(projectPath, "CLAUDE.orchestrator.md");
|
||||
|
||||
if (existsSync(promptPath) && !regenerate) {
|
||||
return; // Already exists and not regenerating
|
||||
}
|
||||
|
||||
const content = generateOrchestratorPrompt({ config, projectId, project });
|
||||
writeFileSync(promptPath, content, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure CLAUDE.local.md imports CLAUDE.orchestrator.md.
|
||||
* This function is idempotent — multiple calls have no additional effect.
|
||||
*/
|
||||
function ensureOrchestratorImport(projectPath: string): void {
|
||||
const localMdPath = join(projectPath, "CLAUDE.local.md");
|
||||
const importLine = "@CLAUDE.orchestrator.md";
|
||||
|
||||
let content = "";
|
||||
if (existsSync(localMdPath)) {
|
||||
content = readFileSync(localMdPath, "utf-8");
|
||||
}
|
||||
|
||||
// Check if import already exists
|
||||
if (content.includes(importLine)) {
|
||||
return; // Already imported
|
||||
}
|
||||
|
||||
// Append import
|
||||
if (content && !content.endsWith("\n")) {
|
||||
content += "\n";
|
||||
}
|
||||
if (content) {
|
||||
content += "\n"; // Blank line separator
|
||||
}
|
||||
content += `${importLine}\n`;
|
||||
|
||||
writeFileSync(localMdPath, content, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve project from config.
|
||||
* If projectArg is provided, use it. If only one project exists, use that.
|
||||
* Otherwise, error with helpful message.
|
||||
*/
|
||||
function resolveProject(
|
||||
config: OrchestratorConfig,
|
||||
projectArg?: string,
|
||||
): { projectId: string; project: ProjectConfig } {
|
||||
const projectIds = Object.keys(config.projects);
|
||||
|
||||
if (projectIds.length === 0) {
|
||||
throw new Error("No projects configured. Add a project to agent-orchestrator.yaml.");
|
||||
}
|
||||
|
||||
// Explicit project argument
|
||||
if (projectArg) {
|
||||
const project = config.projects[projectArg];
|
||||
if (!project) {
|
||||
throw new Error(
|
||||
`Project "${projectArg}" not found. Available projects:\n ${projectIds.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return { projectId: projectArg, project };
|
||||
}
|
||||
|
||||
// Only one project — use it
|
||||
if (projectIds.length === 1) {
|
||||
const projectId = projectIds[0];
|
||||
return { projectId, project: config.projects[projectId] };
|
||||
}
|
||||
|
||||
// Multiple projects, no argument — error
|
||||
throw new Error(
|
||||
`Multiple projects configured. Specify which one to start:\n ${projectIds.map((id) => `ao start ${id}`).join("\n ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start dashboard server in the background.
|
||||
* Returns the child process handle for cleanup.
|
||||
*/
|
||||
function startDashboard(port: number, webDir: string): ChildProcess {
|
||||
const child = spawn("npx", ["next", "dev", "-p", String(port)], {
|
||||
cwd: webDir,
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
console.error(chalk.red("Dashboard failed to start:"), err);
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop dashboard server.
|
||||
* Uses lsof to find the process listening on the port, then kills it.
|
||||
* Best effort — if it fails, just warn the user.
|
||||
*/
|
||||
async function stopDashboard(port: number): Promise<void> {
|
||||
try {
|
||||
// Find PIDs listening on the port (can be multiple: parent + children)
|
||||
const { stdout } = await exec("lsof", ["-ti", `:${port}`]);
|
||||
const pids = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((p) => p.length > 0);
|
||||
|
||||
if (pids.length > 0) {
|
||||
// Kill all processes (pass PIDs as separate arguments)
|
||||
await exec("kill", pids);
|
||||
console.log(chalk.green("Dashboard stopped"));
|
||||
} else {
|
||||
console.log(chalk.yellow(`Dashboard not running on port ${port}`));
|
||||
}
|
||||
} catch {
|
||||
console.log(chalk.yellow("Could not stop dashboard (may not be running)"));
|
||||
}
|
||||
}
|
||||
|
||||
export function registerStart(program: Command): void {
|
||||
program
|
||||
.command("start [project]")
|
||||
.description("Start orchestrator agent and dashboard for a project")
|
||||
.option("--no-dashboard", "Skip starting the dashboard server")
|
||||
.option("--no-orchestrator", "Skip starting the orchestrator agent")
|
||||
.option("--regenerate", "Regenerate CLAUDE.orchestrator.md")
|
||||
.action(
|
||||
async (
|
||||
projectArg?: string,
|
||||
opts?: { dashboard?: boolean; orchestrator?: boolean; regenerate?: boolean },
|
||||
) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const { projectId, project } = resolveProject(config, projectArg);
|
||||
const sessionId = `${project.sessionPrefix}-orchestrator`;
|
||||
const port = config.port;
|
||||
|
||||
console.log(chalk.bold(`\nStarting orchestrator for ${chalk.cyan(project.name)}\n`));
|
||||
|
||||
// Start dashboard (unless --no-dashboard)
|
||||
const spinner = ora();
|
||||
let dashboardProcess: ChildProcess | null = null;
|
||||
let exists = false; // Track whether orchestrator session already exists
|
||||
|
||||
if (opts?.dashboard !== false) {
|
||||
spinner.start("Starting dashboard");
|
||||
const webDir = findWebDir();
|
||||
if (!existsSync(resolve(webDir, "package.json"))) {
|
||||
spinner.fail("Dashboard not found");
|
||||
throw new Error(
|
||||
"Could not find @composio/ao-web package. Run: pnpm install",
|
||||
);
|
||||
}
|
||||
|
||||
dashboardProcess = startDashboard(port, webDir);
|
||||
spinner.succeed(`Dashboard starting on http://localhost:${port}`);
|
||||
console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n"));
|
||||
}
|
||||
|
||||
// Create orchestrator tmux session (unless --no-orchestrator or already exists)
|
||||
if (opts?.orchestrator !== false) {
|
||||
// Check if orchestrator session already exists
|
||||
exists = await hasTmuxSession(sessionId);
|
||||
|
||||
if (exists) {
|
||||
console.log(chalk.yellow(`Orchestrator session "${sessionId}" is already running (skipping creation)`));
|
||||
} else {
|
||||
try {
|
||||
// Ensure CLAUDE.orchestrator.md exists
|
||||
spinner.start("Generating orchestrator prompt");
|
||||
ensureOrchestratorPrompt(
|
||||
project.path,
|
||||
config,
|
||||
projectId,
|
||||
project,
|
||||
opts?.regenerate ?? false,
|
||||
);
|
||||
spinner.succeed("Orchestrator prompt ready");
|
||||
|
||||
// Ensure CLAUDE.local.md imports CLAUDE.orchestrator.md
|
||||
spinner.start("Configuring CLAUDE.local.md");
|
||||
ensureOrchestratorImport(project.path);
|
||||
spinner.succeed("CLAUDE.local.md configured");
|
||||
|
||||
// Get agent instance (used for hooks and launch)
|
||||
const agent = getAgent(config, projectId);
|
||||
|
||||
// Setup agent hooks for automatic metadata updates
|
||||
spinner.start("Configuring agent hooks");
|
||||
if (agent.setupWorkspaceHooks) {
|
||||
await agent.setupWorkspaceHooks(project.path, { dataDir: config.dataDir });
|
||||
}
|
||||
spinner.succeed("Agent hooks configured");
|
||||
|
||||
spinner.start("Creating orchestrator session");
|
||||
|
||||
// Get agent launch command
|
||||
const launchCmd = agent.getLaunchCommand({
|
||||
sessionId,
|
||||
projectConfig: project,
|
||||
permissions: project.agentConfig?.permissions ?? "default",
|
||||
model: project.agentConfig?.model,
|
||||
});
|
||||
|
||||
// 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: config.dataDir,
|
||||
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(config.dataDir, 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;
|
||||
}
|
||||
} catch (err) {
|
||||
spinner.fail("Orchestrator setup failed");
|
||||
// Cleanup dashboard if orchestrator setup fails
|
||||
if (dashboardProcess) {
|
||||
dashboardProcess.kill();
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary based on what was actually started
|
||||
console.log(chalk.bold.green("\n✓ Startup complete\n"));
|
||||
|
||||
if (opts?.dashboard !== false) {
|
||||
console.log(chalk.cyan("Dashboard:"), `http://localhost:${port}`);
|
||||
}
|
||||
|
||||
if (opts?.orchestrator !== false && !exists) {
|
||||
console.log(chalk.cyan("Orchestrator:"), `tmux attach -t ${sessionId}`);
|
||||
} else if (exists) {
|
||||
console.log(chalk.cyan("Orchestrator:"), `already running (${sessionId})`);
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`Config: ${config.dataDir}\n`));
|
||||
|
||||
// Keep dashboard process alive if it was started
|
||||
if (dashboardProcess) {
|
||||
dashboardProcess.on("exit", (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(chalk.red(`Dashboard exited with code ${code}`));
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes("No agent-orchestrator.yaml found")) {
|
||||
console.error(chalk.red("\nNo config found. Run:"));
|
||||
console.error(chalk.cyan(" ao init\n"));
|
||||
} else {
|
||||
console.error(chalk.red("\nError:"), err.message);
|
||||
}
|
||||
} else {
|
||||
console.error(chalk.red("\nError:"), String(err));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function registerStop(program: Command): void {
|
||||
program
|
||||
.command("stop [project]")
|
||||
.description("Stop orchestrator agent and dashboard for a project")
|
||||
.action(async (projectArg?: string) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const { projectId: _projectId, project } = resolveProject(config, projectArg);
|
||||
const sessionId = `${project.sessionPrefix}-orchestrator`;
|
||||
const port = config.port;
|
||||
|
||||
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");
|
||||
|
||||
// Archive metadata
|
||||
deleteMetadata(config.dataDir, sessionId, true);
|
||||
} else {
|
||||
console.log(chalk.yellow(`Orchestrator session "${sessionId}" is not running`));
|
||||
}
|
||||
|
||||
// Stop dashboard
|
||||
await stopDashboard(port);
|
||||
|
||||
console.log(chalk.bold.green("\n✓ Orchestrator stopped\n"));
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
console.error(chalk.red("\nError:"), err.message);
|
||||
} else {
|
||||
console.error(chalk.red("\nError:"), String(err));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { registerSend } from "./commands/send.js";
|
|||
import { registerReviewCheck } from "./commands/review-check.js";
|
||||
import { registerDashboard } from "./commands/dashboard.js";
|
||||
import { registerOpen } from "./commands/open.js";
|
||||
import { registerStart, registerStop } from "./commands/start.js";
|
||||
|
||||
const program = new Command();
|
||||
|
||||
|
|
@ -18,6 +19,8 @@ program
|
|||
.version("0.1.0");
|
||||
|
||||
registerInit(program);
|
||||
registerStart(program);
|
||||
registerStop(program);
|
||||
registerStatus(program);
|
||||
registerSpawn(program);
|
||||
registerBatchSpawn(program);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Web directory locator — finds the @composio/ao-web package.
|
||||
* Shared utility to avoid duplication between dashboard.ts and start.ts.
|
||||
*/
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Locate the @composio/ao-web package directory.
|
||||
* Uses createRequire for ESM-compatible require.resolve, with fallback
|
||||
* to sibling package paths that work from both src/ and dist/.
|
||||
*/
|
||||
export function findWebDir(): string {
|
||||
// Try to resolve from node_modules first (installed as workspace dep)
|
||||
try {
|
||||
const pkgJson = require.resolve("@composio/ao-web/package.json");
|
||||
return resolve(pkgJson, "..");
|
||||
} catch {
|
||||
// Fallback: sibling package in monorepo (works both from src/ and dist/)
|
||||
// packages/cli/src/lib/ → packages/web
|
||||
// packages/cli/dist/lib/ → packages/web
|
||||
const candidates = [
|
||||
resolve(__dirname, "../../../web"),
|
||||
resolve(__dirname, "../../../../packages/web"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(resolve(candidate, "package.json"))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return candidates[0];
|
||||
}
|
||||
}
|
||||
|
|
@ -48,5 +48,9 @@ export type { LifecycleManagerDeps } from "./lifecycle-manager.js";
|
|||
export { buildPrompt, BASE_AGENT_PROMPT } from "./prompt-builder.js";
|
||||
export type { PromptBuildConfig } from "./prompt-builder.js";
|
||||
|
||||
// Orchestrator prompt — generates CLAUDE.orchestrator.md
|
||||
export { generateOrchestratorPrompt } from "./orchestrator-prompt.js";
|
||||
export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js";
|
||||
|
||||
// Shared utilities
|
||||
export { shellEscape, escapeAppleScript, validateUrl } from "./utils.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* Orchestrator Prompt Generator — generates CLAUDE.orchestrator.md content.
|
||||
*
|
||||
* This file is imported into CLAUDE.local.md (gitignored) in the main checkout
|
||||
* to provide orchestrator-specific context when the orchestrator agent runs.
|
||||
*/
|
||||
|
||||
import type { OrchestratorConfig, ProjectConfig } from "./types.js";
|
||||
|
||||
export interface OrchestratorPromptConfig {
|
||||
config: OrchestratorConfig;
|
||||
projectId: string;
|
||||
project: ProjectConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate markdown content for CLAUDE.orchestrator.md.
|
||||
* Provides orchestrator agent with context about available commands,
|
||||
* session management workflows, and project configuration.
|
||||
*/
|
||||
export function generateOrchestratorPrompt(opts: OrchestratorPromptConfig): string {
|
||||
const { config, projectId, project } = opts;
|
||||
const sections: string[] = [];
|
||||
|
||||
// Header
|
||||
sections.push(`# CLAUDE.orchestrator.md - ${project.name} Orchestrator
|
||||
|
||||
You are the **orchestrator agent** for the ${project.name} project.
|
||||
|
||||
Your role is to coordinate and manage worker agent sessions. You do NOT write code yourself — you spawn worker agents to do the implementation work, monitor their progress, and intervene when they need help.`);
|
||||
|
||||
// Project Info
|
||||
sections.push(`## Project Info
|
||||
|
||||
- **Name**: ${project.name}
|
||||
- **Repository**: ${project.repo}
|
||||
- **Default Branch**: ${project.defaultBranch}
|
||||
- **Session Prefix**: ${project.sessionPrefix}
|
||||
- **Local Path**: ${project.path}
|
||||
- **Data Directory**: ${config.dataDir}
|
||||
- **Worktree Directory**: ${config.worktreeDir}
|
||||
- **Dashboard Port**: ${config.port}`);
|
||||
|
||||
// Quick Start
|
||||
sections.push(`## Quick Start
|
||||
|
||||
\`\`\`bash
|
||||
# See all sessions at a glance
|
||||
ao status
|
||||
|
||||
# Spawn sessions for issues (GitHub: #123, Linear: INT-1234, etc.)
|
||||
ao spawn ${projectId} INT-1234
|
||||
ao batch-spawn ${projectId} INT-1 INT-2 INT-3
|
||||
|
||||
# List sessions
|
||||
ao session ls -p ${projectId}
|
||||
|
||||
# Send message to a session
|
||||
ao send ${project.sessionPrefix}-1 "Your message here"
|
||||
|
||||
# Kill a session
|
||||
ao session kill ${project.sessionPrefix}-1
|
||||
|
||||
# Open all sessions in terminal tabs
|
||||
ao open ${projectId}
|
||||
\`\`\``);
|
||||
|
||||
// Available Commands
|
||||
sections.push(`## Available Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| \`ao status\` | Show all sessions with PR/CI/review status |
|
||||
| \`ao spawn <project> [issue]\` | Spawn a single worker agent session |
|
||||
| \`ao batch-spawn <project> <issues...>\` | Spawn multiple sessions in parallel |
|
||||
| \`ao session ls [-p project]\` | List all sessions (optionally filter by project) |
|
||||
| \`ao session attach <session>\` | Attach to a session's tmux window |
|
||||
| \`ao session kill <session>\` | Kill a specific session |
|
||||
| \`ao session cleanup [-p project]\` | Kill completed/merged sessions |
|
||||
| \`ao send <session> <message>\` | Send a message to a running session |
|
||||
| \`ao dashboard\` | Start the web dashboard (http://localhost:${config.port}) |
|
||||
| \`ao open <project>\` | Open all project sessions in terminal tabs |`);
|
||||
|
||||
// Session Management
|
||||
sections.push(`## Session Management
|
||||
|
||||
### Spawning Sessions
|
||||
|
||||
When you spawn a session:
|
||||
1. A git worktree is created from \`${project.defaultBranch}\`
|
||||
2. A feature branch is created (e.g., \`feat/INT-1234\`)
|
||||
3. A tmux session is started (e.g., \`${project.sessionPrefix}-1\`)
|
||||
4. The agent is launched with context about the issue
|
||||
5. Metadata is written to \`${config.dataDir}\`
|
||||
|
||||
### Monitoring Progress
|
||||
|
||||
Use \`ao status\` to see:
|
||||
- Current session status (working, pr_open, review_pending, etc.)
|
||||
- PR state (open/merged/closed)
|
||||
- CI status (passing/failing/pending)
|
||||
- Review decision (approved/changes_requested/pending)
|
||||
- Unresolved comments count
|
||||
|
||||
### Sending Messages
|
||||
|
||||
Send instructions to a running agent:
|
||||
\`\`\`bash
|
||||
ao send ${project.sessionPrefix}-1 "Please address the review comments on your PR"
|
||||
\`\`\`
|
||||
|
||||
### Cleanup
|
||||
|
||||
Remove completed sessions:
|
||||
\`\`\`bash
|
||||
ao session cleanup -p ${projectId} # Kill sessions where PR is merged or issue is closed
|
||||
\`\`\``);
|
||||
|
||||
// Dashboard
|
||||
sections.push(`## Dashboard
|
||||
|
||||
The web dashboard runs at **http://localhost:${config.port}**.
|
||||
|
||||
Features:
|
||||
- Live session cards with activity status
|
||||
- PR table with CI checks and review state
|
||||
- Attention zones (merge ready, needs response, working, done)
|
||||
- One-click actions (send message, kill, merge PR)
|
||||
- Real-time updates via Server-Sent Events`);
|
||||
|
||||
// Reactions (if configured)
|
||||
if (project.reactions && Object.keys(project.reactions).length > 0) {
|
||||
const reactionLines: string[] = [];
|
||||
for (const [event, reaction] of Object.entries(project.reactions)) {
|
||||
if (reaction.auto && reaction.action === "send-to-agent") {
|
||||
reactionLines.push(`- **${event}**: Auto-sends instruction to agent (retries: ${reaction.retries ?? "none"}, escalates after: ${reaction.escalateAfter ?? "never"})`);
|
||||
} else if (reaction.auto && reaction.action === "notify") {
|
||||
reactionLines.push(`- **${event}**: Notifies human (priority: ${reaction.priority ?? "info"})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (reactionLines.length > 0) {
|
||||
sections.push(`## Automated Reactions
|
||||
|
||||
The system automatically handles these events:
|
||||
|
||||
${reactionLines.join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Workflows
|
||||
sections.push(`## Common Workflows
|
||||
|
||||
### Bulk Issue Processing
|
||||
1. Get list of issues from tracker (GitHub/Linear/etc.)
|
||||
2. Use \`ao batch-spawn\` to spawn sessions for each issue
|
||||
3. Monitor with \`ao status\` or the dashboard
|
||||
4. Agents will fetch, implement, test, PR, and respond to reviews
|
||||
5. Use \`ao session cleanup\` when PRs are merged
|
||||
|
||||
### Handling Stuck Agents
|
||||
1. Check \`ao status\` for sessions in "stuck" or "needs_input" state
|
||||
2. Attach with \`ao session attach <session>\` to see what they're doing
|
||||
3. Send clarification or instructions with \`ao send <session> '...'\`
|
||||
4. Or kill and respawn with fresh context if needed
|
||||
|
||||
### PR Review Flow
|
||||
1. Agent creates PR and pushes
|
||||
2. CI runs automatically
|
||||
3. If CI fails: reaction auto-sends fix instructions to agent
|
||||
4. If reviewers request changes: reaction auto-sends comments to agent
|
||||
5. When approved + green: notify human to merge (unless auto-merge enabled)
|
||||
|
||||
### Manual Intervention
|
||||
When an agent needs human judgment:
|
||||
1. You'll get a notification (desktop/slack/webhook)
|
||||
2. Check the dashboard or \`ao status\` for details
|
||||
3. Attach to the session if needed: \`ao session attach <session>\`
|
||||
4. Send instructions: \`ao send <session> '...'\`
|
||||
5. Or handle it yourself (merge PR, close issue, etc.)`);
|
||||
|
||||
// Tips
|
||||
sections.push(`## Tips
|
||||
|
||||
1. **Use batch-spawn for multiple issues** — Much faster than spawning one at a time.
|
||||
|
||||
2. **Check status before spawning** — Avoid creating duplicate sessions for issues already being worked on.
|
||||
|
||||
3. **Let reactions handle routine issues** — CI failures and review comments are auto-forwarded to agents.
|
||||
|
||||
4. **Trust the metadata** — Session files in \`${config.dataDir}\` track branch, PR, status, etc.
|
||||
|
||||
5. **Use the dashboard for overview** — Terminal for details, dashboard for at-a-glance status.
|
||||
|
||||
6. **Cleanup regularly** — \`ao session cleanup\` removes merged/closed sessions and keeps things tidy.
|
||||
|
||||
7. **Monitor the event log** — Check \`${config.dataDir}/events.jsonl\` for full system activity history.
|
||||
|
||||
8. **Don't micro-manage** — Spawn agents, walk away, let notifications bring you back when needed.`);
|
||||
|
||||
// Project-specific rules (if any)
|
||||
if (project.orchestratorRules) {
|
||||
sections.push(`## Project-Specific Rules
|
||||
|
||||
${project.orchestratorRules}`);
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
|
@ -207,6 +207,21 @@ export interface Agent {
|
|||
|
||||
/** Optional: run setup after agent is launched (e.g. configure MCP servers) */
|
||||
postLaunchSetup?(session: Session): Promise<void>;
|
||||
|
||||
/**
|
||||
* Optional: Set up agent-specific hooks/config in the workspace for automatic metadata updates.
|
||||
* Called once per workspace during ao init/start and when creating new worktrees.
|
||||
*
|
||||
* Each agent plugin implements this for their own config format:
|
||||
* - Claude Code: writes .claude/settings.json with PostToolUse hook
|
||||
* - Codex: whatever config mechanism Codex uses
|
||||
* - Aider: .aider.conf.yml or similar
|
||||
* - OpenCode: its own config
|
||||
*
|
||||
* CRITICAL: The dashboard depends on metadata being auto-updated when agents
|
||||
* run git/gh commands. Without this, PRs created by agents never show up.
|
||||
*/
|
||||
setupWorkspaceHooks?(workspacePath: string, config: WorkspaceHooksConfig): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AgentLaunchConfig {
|
||||
|
|
@ -218,6 +233,13 @@ export interface AgentLaunchConfig {
|
|||
model?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceHooksConfig {
|
||||
/** Data directory where session metadata files are stored */
|
||||
dataDir: string;
|
||||
/** Optional session ID (may not be known at ao init time) */
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionInfo {
|
||||
/** Agent's auto-generated summary of what it's working on */
|
||||
summary: string | null;
|
||||
|
|
|
|||
|
|
@ -155,10 +155,10 @@ describe("getLaunchCommand", () => {
|
|||
describe("getEnvironment", () => {
|
||||
const agent = create();
|
||||
|
||||
it("sets AO_SESSION_ID and AO_PROJECT_ID", () => {
|
||||
it("sets AO_SESSION_ID but not AO_PROJECT_ID (caller's responsibility)", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["AO_SESSION_ID"]).toBe("sess-1");
|
||||
expect(env["AO_PROJECT_ID"]).toBe("my-project");
|
||||
expect(env["AO_PROJECT_ID"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets AO_ISSUE_ID when provided", () => {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ function createAiderAgent(): Agent {
|
|||
getEnvironment(config: AgentLaunchConfig): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
env["AO_SESSION_ID"] = config.sessionId;
|
||||
env["AO_PROJECT_ID"] = config.projectConfig.name;
|
||||
// NOTE: AO_PROJECT_ID is the caller's responsibility (spawn.ts sets it)
|
||||
if (config.issueId) {
|
||||
env["AO_ISSUE_ID"] = config.issueId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,10 +202,10 @@ describe("getEnvironment", () => {
|
|||
expect(env["CLAUDECODE"]).toBe("");
|
||||
});
|
||||
|
||||
it("sets AO_SESSION_ID and AO_PROJECT_ID", () => {
|
||||
it("sets AO_SESSION_ID but not AO_PROJECT_ID (caller's responsibility)", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["AO_SESSION_ID"]).toBe("sess-1");
|
||||
expect(env["AO_PROJECT_ID"]).toBe("my-project");
|
||||
expect(env["AO_PROJECT_ID"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets AO_ISSUE_ID when provided", () => {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type PluginModule,
|
||||
type RuntimeHandle,
|
||||
type Session,
|
||||
type WorkspaceHooksConfig,
|
||||
} from "@composio/ao-core";
|
||||
import { execFile } from "node:child_process";
|
||||
import { open, readdir, readFile, stat, writeFile, mkdir, chmod } from "node:fs/promises";
|
||||
|
|
@ -71,7 +72,14 @@ if [[ -z "\${AO_SESSION:-}" ]]; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
metadata_file="$AO_DATA_DIR/$AO_SESSION"
|
||||
# Construct metadata file path (supports both flat and project-specific directories)
|
||||
if [[ -n "\${AO_PROJECT_ID:-}" ]]; then
|
||||
# Project-specific directory (used by spawn command)
|
||||
metadata_file="$AO_DATA_DIR/\${AO_PROJECT_ID}-sessions/$AO_SESSION"
|
||||
else
|
||||
# Flat directory (used by orchestrator)
|
||||
metadata_file="$AO_DATA_DIR/$AO_SESSION"
|
||||
fi
|
||||
|
||||
# Ensure metadata file exists
|
||||
if [[ ! -f "$metadata_file" ]]; then
|
||||
|
|
@ -493,6 +501,97 @@ function classifyTerminalOutput(terminalOutput: string): ActivityState {
|
|||
return "active";
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook Setup Helper
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Shared helper to setup PostToolUse hooks in a workspace.
|
||||
* Writes metadata-updater.sh script and updates settings.json.
|
||||
*
|
||||
* @param workspacePath - Path to the workspace directory
|
||||
* @param hookCommand - Command string for the hook (can use variables like $CLAUDE_PROJECT_DIR)
|
||||
*/
|
||||
async function setupHookInWorkspace(workspacePath: string, hookCommand: string): Promise<void> {
|
||||
const claudeDir = join(workspacePath, ".claude");
|
||||
const settingsPath = join(claudeDir, "settings.json");
|
||||
const hookScriptPath = join(claudeDir, "metadata-updater.sh");
|
||||
|
||||
// Create .claude directory if it doesn't exist
|
||||
try {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
} catch {
|
||||
// Directory might already exist
|
||||
}
|
||||
|
||||
// Write the metadata updater script
|
||||
await writeFile(hookScriptPath, METADATA_UPDATER_SCRIPT, "utf-8");
|
||||
await chmod(hookScriptPath, 0o755); // Make executable
|
||||
|
||||
// Read existing settings if present
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = await readFile(settingsPath, "utf-8");
|
||||
existingSettings = JSON.parse(content) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Invalid JSON or read error — start fresh
|
||||
}
|
||||
}
|
||||
|
||||
// Merge hooks configuration
|
||||
const hooks = (existingSettings["hooks"] as Record<string, unknown>) ?? {};
|
||||
const postToolUse = (hooks["PostToolUse"] as Array<unknown>) ?? [];
|
||||
|
||||
// Check if our hook is already configured
|
||||
let hookIndex = -1;
|
||||
let hookDefIndex = -1;
|
||||
for (let i = 0; i < postToolUse.length; i++) {
|
||||
const hook = postToolUse[i];
|
||||
if (typeof hook !== "object" || hook === null || Array.isArray(hook)) continue;
|
||||
const h = hook as Record<string, unknown>;
|
||||
const hooksList = h["hooks"];
|
||||
if (!Array.isArray(hooksList)) continue;
|
||||
for (let j = 0; j < hooksList.length; j++) {
|
||||
const hDef = hooksList[j];
|
||||
if (typeof hDef !== "object" || hDef === null || Array.isArray(hDef)) continue;
|
||||
const def = hDef as Record<string, unknown>;
|
||||
if (typeof def["command"] === "string" && def["command"].includes("metadata-updater.sh")) {
|
||||
hookIndex = i;
|
||||
hookDefIndex = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hookIndex >= 0) break;
|
||||
}
|
||||
|
||||
// Add or update our hook
|
||||
if (hookIndex === -1) {
|
||||
// No metadata hook exists, add it
|
||||
postToolUse.push({
|
||||
matcher: "Bash",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: hookCommand,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
// Hook exists, update the command
|
||||
const hook = postToolUse[hookIndex] as Record<string, unknown>;
|
||||
const hooksList = hook["hooks"] as Array<Record<string, unknown>>;
|
||||
hooksList[hookDefIndex]["command"] = hookCommand;
|
||||
}
|
||||
|
||||
hooks["PostToolUse"] = postToolUse;
|
||||
existingSettings["hooks"] = hooks;
|
||||
|
||||
// Write updated settings
|
||||
await writeFile(settingsPath, JSON.stringify(existingSettings, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Agent Implementation
|
||||
// =============================================================================
|
||||
|
|
@ -530,7 +629,12 @@ function createClaudeCodeAgent(): Agent {
|
|||
|
||||
// Set session info for introspection
|
||||
env["AO_SESSION_ID"] = config.sessionId;
|
||||
env["AO_PROJECT_ID"] = config.projectConfig.name;
|
||||
|
||||
// NOTE: AO_PROJECT_ID is NOT set here - it's the caller's responsibility
|
||||
// to set it based on their metadata path scheme:
|
||||
// - spawn.ts sets it to projectId for project-specific directories
|
||||
// - start.ts omits it for orchestrator (flat directories)
|
||||
// - session manager omits it (flat directories)
|
||||
|
||||
if (config.issueId) {
|
||||
env["AO_ISSUE_ID"] = config.issueId;
|
||||
|
|
@ -605,87 +709,18 @@ function createClaudeCodeAgent(): Agent {
|
|||
};
|
||||
},
|
||||
|
||||
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
// Use absolute path for hook command (specific to this workspace)
|
||||
const hookScriptPath = join(workspacePath, ".claude", "metadata-updater.sh");
|
||||
await setupHookInWorkspace(workspacePath, hookScriptPath);
|
||||
},
|
||||
|
||||
async postLaunchSetup(session: Session): Promise<void> {
|
||||
if (!session.workspacePath) return;
|
||||
|
||||
// Path to Claude settings directory in workspace
|
||||
const claudeDir = join(session.workspacePath, ".claude");
|
||||
const settingsPath = join(claudeDir, "settings.json");
|
||||
const hookScriptPath = join(claudeDir, "metadata-updater.sh");
|
||||
|
||||
// Create .claude directory if it doesn't exist
|
||||
try {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
} catch {
|
||||
// Directory might already exist
|
||||
}
|
||||
|
||||
// Write the metadata updater script to the workspace
|
||||
await writeFile(hookScriptPath, METADATA_UPDATER_SCRIPT, "utf-8");
|
||||
await chmod(hookScriptPath, 0o755); // Make executable
|
||||
|
||||
// Read existing settings if present
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = await readFile(settingsPath, "utf-8");
|
||||
existingSettings = JSON.parse(content) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Invalid JSON or read error — start fresh
|
||||
}
|
||||
}
|
||||
|
||||
// Merge hooks configuration
|
||||
const hooks = (existingSettings["hooks"] as Record<string, unknown>) ?? {};
|
||||
const postToolUse = (hooks["PostToolUse"] as Array<unknown>) ?? [];
|
||||
|
||||
// Check if our hook is already configured with the correct path
|
||||
let hookIndex = -1;
|
||||
let hookDefIndex = -1;
|
||||
for (let i = 0; i < postToolUse.length; i++) {
|
||||
const hook = postToolUse[i];
|
||||
if (typeof hook !== "object" || hook === null || Array.isArray(hook)) continue;
|
||||
const h = hook as Record<string, unknown>;
|
||||
const hooksList = h["hooks"];
|
||||
if (!Array.isArray(hooksList)) continue;
|
||||
for (let j = 0; j < hooksList.length; j++) {
|
||||
const hDef = hooksList[j];
|
||||
if (typeof hDef !== "object" || hDef === null || Array.isArray(hDef)) continue;
|
||||
const def = hDef as Record<string, unknown>;
|
||||
if (typeof def["command"] === "string" && def["command"].includes("metadata-updater.sh")) {
|
||||
hookIndex = i;
|
||||
hookDefIndex = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hookIndex >= 0) break;
|
||||
}
|
||||
|
||||
// Add or update our hook to ensure it points to the current workspace
|
||||
if (hookIndex === -1) {
|
||||
// No metadata hook exists, add it
|
||||
postToolUse.push({
|
||||
matcher: "Bash",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: hookScriptPath,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
// Hook exists, update the path to current workspace
|
||||
const hook = postToolUse[hookIndex] as Record<string, unknown>;
|
||||
const hooksList = hook["hooks"] as Array<Record<string, unknown>>;
|
||||
hooksList[hookDefIndex]["command"] = hookScriptPath;
|
||||
}
|
||||
|
||||
hooks["PostToolUse"] = postToolUse;
|
||||
existingSettings["hooks"] = hooks;
|
||||
|
||||
// Write updated settings
|
||||
await writeFile(settingsPath, JSON.stringify(existingSettings, null, 2) + "\n", "utf-8");
|
||||
// Use absolute path for hook command (specific to this workspace)
|
||||
const hookScriptPath = join(session.workspacePath, ".claude", "metadata-updater.sh");
|
||||
await setupHookInWorkspace(session.workspacePath, hookScriptPath);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,10 +154,10 @@ describe("getLaunchCommand", () => {
|
|||
describe("getEnvironment", () => {
|
||||
const agent = create();
|
||||
|
||||
it("sets AO_SESSION_ID and AO_PROJECT_ID", () => {
|
||||
it("sets AO_SESSION_ID but not AO_PROJECT_ID (caller's responsibility)", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["AO_SESSION_ID"]).toBe("sess-1");
|
||||
expect(env["AO_PROJECT_ID"]).toBe("my-project");
|
||||
expect(env["AO_PROJECT_ID"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets AO_ISSUE_ID when provided", () => {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ function createCodexAgent(): Agent {
|
|||
getEnvironment(config: AgentLaunchConfig): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
env["AO_SESSION_ID"] = config.sessionId;
|
||||
env["AO_PROJECT_ID"] = config.projectConfig.name;
|
||||
// NOTE: AO_PROJECT_ID is the caller's responsibility (spawn.ts sets it)
|
||||
if (config.issueId) {
|
||||
env["AO_ISSUE_ID"] = config.issueId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,10 +149,10 @@ describe("getLaunchCommand", () => {
|
|||
describe("getEnvironment", () => {
|
||||
const agent = create();
|
||||
|
||||
it("sets AO_SESSION_ID and AO_PROJECT_ID", () => {
|
||||
it("sets AO_SESSION_ID but not AO_PROJECT_ID (caller's responsibility)", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["AO_SESSION_ID"]).toBe("sess-1");
|
||||
expect(env["AO_PROJECT_ID"]).toBe("my-project");
|
||||
expect(env["AO_PROJECT_ID"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets AO_ISSUE_ID when provided", () => {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ function createOpenCodeAgent(): Agent {
|
|||
getEnvironment(config: AgentLaunchConfig): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
env["AO_SESSION_ID"] = config.sessionId;
|
||||
env["AO_PROJECT_ID"] = config.projectConfig.name;
|
||||
// NOTE: AO_PROJECT_ID is the caller's responsibility (spawn.ts sets it)
|
||||
if (config.issueId) {
|
||||
env["AO_ISSUE_ID"] = config.issueId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,15 @@ export default async function Home() {
|
|||
try {
|
||||
const { config, registry, sessionManager } = await getServices();
|
||||
const allSessions = await sessionManager.list();
|
||||
// Find and filter out orchestrator sessions — they get their own button, not a card
|
||||
|
||||
// Find the orchestrator session (any session ending with -orchestrator)
|
||||
// Only set orchestratorId if an actual session exists (no fallback)
|
||||
const orchSession = allSessions.find((s) => s.id.endsWith("-orchestrator"));
|
||||
if (orchSession) orchestratorId = orchSession.id;
|
||||
if (orchSession) {
|
||||
orchestratorId = orchSession.id;
|
||||
}
|
||||
|
||||
// Filter out orchestrator from worker sessions
|
||||
const coreSessions = allSessions.filter((s) => !s.id.endsWith("-orchestrator"));
|
||||
sessions = coreSessions.map(sessionToDashboard);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue