|
/** |
|
* Main Orchestrator - runs the top-level loop with FYI injection |
|
* |
|
* Key features: |
|
* - Injects FYI messages when subagents complete |
|
* - Handles interactive mode (question forwarding) |
|
* - Manages the MCP server for SpawnAgents/ListAgents tools |
|
*/ |
|
|
|
import { query, type SDKMessage, type SDKUserMessage, type CanUseTool, type PermissionResult } from "@anthropic-ai/claude-agent-sdk"; |
|
import { createInteractiveHandler, createNonInteractiveHandler, type InteractiveHandler, type ChatMessage, type AgentInfo } from "./interactive.jsx"; |
|
import type { FYIMessage, OrchestratorConfig, RetryConfig, PermissionMode, PermissionDecision } from "./types.js"; |
|
import { |
|
generateId, |
|
generateTraceId, |
|
nowISO, |
|
dateDirName, |
|
timestampedDirName, |
|
permissionCacheKey, |
|
DEFAULT_CONFIG, |
|
DEFAULT_LOG_BASE, |
|
DEFAULT_PRIORITY, |
|
DEFAULT_TIMEOUT_MS, |
|
DEFAULT_RETRY_CONFIG, |
|
type RunStats, |
|
type ResumableState, |
|
type SavedRunState, |
|
type SavedAgentState, |
|
type SpawnAgentsItem, |
|
type SpawnAgentsOptions, |
|
} from "./types.js"; |
|
import { AgentManager } from "./agent-manager.js"; |
|
import { createOrchestraMcpServer } from "./mcp-tools.js"; |
|
import { JSONLLogger, LoggerContext, DeferredLoggerContext, createRunLogger } from "./logging.js"; |
|
import { getSettings, saveSettings } from "./settings.js"; |
|
import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync } from "node:fs"; |
|
import { join, dirname } from "node:path"; |
|
|
|
// ============================================================================= |
|
// Orchestrator |
|
// ============================================================================= |
|
|
|
export interface OrchestratorOptions { |
|
sessionId?: string; |
|
logBaseDir?: string; |
|
maxConcurrent?: number; |
|
defaultDepth?: number; |
|
defaultPriority?: number; |
|
defaultTimeoutMs?: number; |
|
defaultRetryConfig?: RetryConfig; |
|
interactive?: boolean; |
|
// Claude CLI passthrough options |
|
permissionMode?: PermissionMode; |
|
model?: string; |
|
allowedTools?: string[]; |
|
disallowedTools?: string[]; |
|
/** Inject prompt hierarchy reminder after compaction (default: true) */ |
|
compactReminder?: boolean; |
|
/** Show all SDK messages for debugging (clo level) */ |
|
debug?: boolean; |
|
/** Pass --debug to Claude SDK */ |
|
debugSdk?: boolean; |
|
/** Called when an agent completes */ |
|
onAgentComplete?: (fyi: FYIMessage) => void; |
|
/** Called when all agents in a run complete */ |
|
onRunComplete?: (runId: string) => void; |
|
} |
|
|
|
export class Orchestrator { |
|
private config: OrchestratorConfig; |
|
private manager: AgentManager; |
|
private mcpServer: ReturnType<typeof createOrchestraMcpServer>; |
|
private fyiQueue: FYIMessage[] = []; |
|
private logger: JSONLLogger | null = null; |
|
private isRunning = false; |
|
private abortController: AbortController | null = null; |
|
private interactiveHandler: InteractiveHandler; |
|
private promptHierarchy: string[] = []; // Track prompt hierarchy for compact reminders |
|
private pendingCompactReminder = false; // Flag to inject reminder after compact |
|
private pendingLogInfoInjection = false; // Flag to inject log info after session_id capture |
|
private currentRunId: string | null = null; // Current run ID for log path construction |
|
private dateDir: string | null = null; // Date directory (YYYY-MM-DD) |
|
private sessionDir: string | null = null; // Timestamped session directory name |
|
private runStartTime: number = 0; // When current activity period started |
|
private accumulatedElapsedMs: number = 0; // Total elapsed time from previous activity periods |
|
private wasActive: boolean = false; // Track previous active state for pause/resume |
|
private hasHadActivity: boolean = false; // Has there been any activity yet? |
|
private isResuming = false; // Whether we're resuming from saved state |
|
private permissionCache: Map<string, PermissionDecision> = new Map(); // Permission fusion cache |
|
// Aggregate token stats across all agents |
|
private tokenStats = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; |
|
private totalCostUsd = 0; |
|
private totalTurns = 0; |
|
// User input queue for interactive chat |
|
private userInputQueue: string[] = []; |
|
private agentInputQueues: Map<string, string[]> = new Map(); |
|
// Track if main loop is actively processing (not waiting for user input) |
|
private mainLoopActive = false; |
|
|
|
constructor(opts: OrchestratorOptions = {}) { |
|
this.config = { |
|
sessionId: opts.sessionId ?? generateId("session"), |
|
logBaseDir: opts.logBaseDir ?? DEFAULT_LOG_BASE, |
|
maxConcurrent: opts.maxConcurrent ?? DEFAULT_CONFIG.maxConcurrent!, |
|
defaultDepth: opts.defaultDepth ?? DEFAULT_CONFIG.defaultDepth!, |
|
defaultPriority: opts.defaultPriority ?? DEFAULT_PRIORITY, |
|
defaultTimeoutMs: opts.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS, |
|
defaultRetryConfig: opts.defaultRetryConfig ?? DEFAULT_RETRY_CONFIG, |
|
interactive: opts.interactive ?? DEFAULT_CONFIG.interactive!, |
|
// Claude CLI passthrough |
|
permissionMode: opts.permissionMode ?? DEFAULT_CONFIG.permissionMode!, |
|
model: opts.model, |
|
allowedTools: opts.allowedTools ?? DEFAULT_CONFIG.allowedTools, |
|
disallowedTools: opts.disallowedTools, |
|
compactReminder: opts.compactReminder ?? DEFAULT_CONFIG.compactReminder!, |
|
debug: opts.debug ?? DEFAULT_CONFIG.debug!, |
|
debugSdk: opts.debugSdk ?? DEFAULT_CONFIG.debugSdk!, |
|
}; |
|
|
|
this.manager = new AgentManager({ |
|
sessionId: this.config.sessionId, |
|
logBaseDir: this.config.logBaseDir, |
|
maxConcurrent: this.config.maxConcurrent, |
|
defaultDepth: this.config.defaultDepth, |
|
defaultPriority: this.config.defaultPriority, |
|
defaultTimeoutMs: this.config.defaultTimeoutMs, |
|
defaultRetryConfig: this.config.defaultRetryConfig, |
|
defaultModel: Orchestrator.parseModel(this.config.model), |
|
}); |
|
|
|
this.mcpServer = createOrchestraMcpServer(this.manager); |
|
|
|
// Create interactive handler based on mode |
|
this.interactiveHandler = this.config.interactive |
|
? createInteractiveHandler() |
|
: createNonInteractiveHandler(); |
|
|
|
// Wire up events |
|
this.manager.on("agentComplete", (fyi: FYIMessage) => { |
|
this.fyiQueue.push(fyi); |
|
opts.onAgentComplete?.(fyi); |
|
}); |
|
|
|
this.manager.on("runComplete", (runId: string) => { |
|
opts.onRunComplete?.(runId); |
|
}); |
|
|
|
// Save state on agent status changes |
|
this.manager.on("stateChanged", () => { |
|
this.saveState(); |
|
}); |
|
|
|
// Wire up user input from interactive UI |
|
this.interactiveHandler.onUserInput((target, content) => { |
|
if (target === "main") { |
|
this.userInputQueue.push(content); |
|
} else { |
|
// Agent-targeted input |
|
const queue = this.agentInputQueues.get(target) ?? []; |
|
queue.push(content); |
|
this.agentInputQueues.set(target, queue); |
|
this.manager.sendToAgent(target, content); |
|
} |
|
}); |
|
} |
|
|
|
/** |
|
* Parse model string to SDK shorthand (returns undefined for full model IDs) |
|
*/ |
|
private static parseModel(model?: string): "sonnet" | "opus" | "haiku" | undefined { |
|
if (!model) return undefined; |
|
const lower = model.toLowerCase(); |
|
if (lower === "sonnet" || lower.includes("sonnet")) return "sonnet"; |
|
if (lower === "opus" || lower.includes("opus")) return "opus"; |
|
if (lower === "haiku" || lower.includes("haiku")) return "haiku"; |
|
return undefined; // Full model ID - let SDK handle it |
|
} |
|
|
|
/** |
|
* Run the orchestrator with FYI injection |
|
* |
|
* This uses an async generator to inject FYI messages into the conversation |
|
* as subagents complete. |
|
*/ |
|
async run(initialPrompt: string): Promise<void> { |
|
const runId = generateId("main"); |
|
const traceId = generateTraceId(); |
|
|
|
this.isRunning = true; |
|
this.currentRunId = runId; |
|
this.abortController = new AbortController(); |
|
this.promptHierarchy = initialPrompt ? ["Main orchestrator: " + initialPrompt] : []; // Track for compact reminders |
|
|
|
// Reset stats for this run |
|
this.tokenStats = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; |
|
this.totalCostUsd = 0; |
|
this.totalTurns = 0; |
|
|
|
// Reset timer state - only start counting when there's activity |
|
this.runStartTime = 0; |
|
this.accumulatedElapsedMs = 0; |
|
this.wasActive = false; |
|
this.hasHadActivity = !!initialPrompt; // If prompt provided, we have activity |
|
if (initialPrompt) { |
|
this.runStartTime = Date.now(); |
|
} |
|
|
|
// Use deferred logging - we'll initialize once we get the SDK's session_id |
|
const logCtx = new DeferredLoggerContext(); |
|
|
|
logCtx.agentCreated({ |
|
itemId: "main", |
|
provider: "anthropic", |
|
model: "default", |
|
depth: this.config.defaultDepth, |
|
context: "Main orchestrator loop", |
|
}); |
|
|
|
console.log(`\n🎭 Clo - Claude Agent Orchestrator\n`); |
|
if (initialPrompt) { |
|
console.log(`Prompt: ${initialPrompt}\n`); |
|
} |
|
|
|
const startTime = Date.now(); |
|
let runStatus: "running" | "completed" | "failed" = "running"; |
|
|
|
// Update stats and agent list periodically |
|
const statsInterval = setInterval(() => { |
|
if (this.config.interactive) { |
|
const agentStats = this.manager.getGlobalStats(); |
|
const totalActive = agentStats.active + (this.mainLoopActive ? 1 : 0); |
|
const isActive = totalActive > 0 || agentStats.queued > 0; |
|
|
|
// Handle timer pause/resume based on activity |
|
if (isActive && !this.wasActive) { |
|
// Becoming active: start/resume timer |
|
this.runStartTime = Date.now(); |
|
this.hasHadActivity = true; |
|
} else if (!isActive && this.wasActive && this.runStartTime > 0) { |
|
// Becoming idle: accumulate elapsed time and pause |
|
this.accumulatedElapsedMs += Date.now() - this.runStartTime; |
|
this.runStartTime = 0; |
|
} |
|
this.wasActive = isActive; |
|
|
|
// Calculate total elapsed: accumulated + current period (if active) |
|
const currentPeriodMs = (isActive && this.runStartTime > 0) ? Date.now() - this.runStartTime : 0; |
|
const totalElapsedMs = this.hasHadActivity ? this.accumulatedElapsedMs + currentPeriodMs : 0; |
|
|
|
this.interactiveHandler.updateStats({ |
|
agents: { |
|
active: totalActive, |
|
queued: agentStats.queued, |
|
completed: agentStats.completed, |
|
failed: agentStats.failed, |
|
}, |
|
tokens: this.tokenStats, |
|
elapsedMs: totalElapsedMs, |
|
costUsd: this.totalCostUsd, |
|
}); |
|
|
|
// Update agent list for navigation |
|
const agentList: AgentInfo[] = this.manager.getAllAgentInfos(); |
|
this.interactiveHandler.updateAgentList(agentList); |
|
} |
|
}, 500); |
|
|
|
try { |
|
// Create the prompt generator with FYI injection |
|
const promptGenerator = this.createPromptGenerator(initialPrompt); |
|
|
|
// Build query options |
|
const queryOptions: Parameters<typeof query>[0]["options"] = { |
|
allowedTools: this.config.allowedTools, |
|
disallowedTools: this.config.disallowedTools, |
|
model: this.config.model, |
|
debug: this.config.debugSdk, |
|
mcpServers: { |
|
// createSdkMcpServer returns { type: 'sdk', name: '...', instance: McpServer } |
|
clo: this.mcpServer, |
|
}, |
|
// Resume the SDK session if we're resuming from saved state |
|
resume: this.isResuming ? this.config.sessionId : undefined, |
|
}; |
|
|
|
// Use canUseTool for interactive mode to enable permission fusion and auto-approval |
|
// of our MCP tools. Otherwise fall back to SDK's permissionMode. |
|
if (this.config.interactive) { |
|
queryOptions.canUseTool = this.createCanUseTool(); |
|
} else { |
|
queryOptions.permissionMode = this.config.permissionMode; |
|
} |
|
|
|
const messages = query({ |
|
prompt: promptGenerator, |
|
options: queryOptions, |
|
}); |
|
|
|
for await (const message of messages) { |
|
// Initialize logging once we have session_id (or immediately if resuming) |
|
if (!logCtx.isInitialized) { |
|
if (this.isResuming) { |
|
// When resuming, use the saved session/date dirs |
|
const fullSessionPath = `${this.dateDir}/${this.sessionDir}`; |
|
this.manager.setSessionId(fullSessionPath); |
|
|
|
this.logger = createRunLogger(fullSessionPath, runId, this.config.logBaseDir, { traceId }); |
|
logCtx.initialize(this.logger.child({ agentId: "main", agentName: "orchestrator" })); |
|
|
|
console.log(`Resumed session: ${this.config.sessionId}`); |
|
console.log(`Logs: ${this.config.logBaseDir}/${fullSessionPath}/`); |
|
console.log("─".repeat(50) + "\n"); |
|
} else if ("session_id" in message && message.session_id) { |
|
// New session: capture session_id from first SDK message |
|
this.config.sessionId = message.session_id; |
|
// Create dated directory structure: YYYY-MM-DD/YYYY-MM-DD-HHmm-{session_id} |
|
const now = new Date(); |
|
this.dateDir = dateDirName(now); |
|
this.sessionDir = timestampedDirName(this.config.sessionId, now); |
|
const fullSessionPath = `${this.dateDir}/${this.sessionDir}`; |
|
this.manager.setSessionId(fullSessionPath); |
|
|
|
this.logger = createRunLogger(fullSessionPath, runId, this.config.logBaseDir, { traceId }); |
|
logCtx.initialize(this.logger.child({ agentId: "main", agentName: "orchestrator" })); |
|
|
|
// Trigger log info injection to inform the agent about its logs |
|
this.pendingLogInfoInjection = true; |
|
|
|
// Save initial state for resume capability |
|
this.saveState(); |
|
|
|
console.log(`Session: ${this.config.sessionId}`); |
|
console.log(`Logs: ${this.config.logBaseDir}/${fullSessionPath}/`); |
|
console.log("─".repeat(50) + "\n"); |
|
} |
|
} |
|
|
|
// Track main loop activity based on message type |
|
// Active when Claude is responding, inactive when turn completes |
|
if (message.type === "assistant") { |
|
this.mainLoopActive = true; |
|
} else if (message.type === "result") { |
|
this.mainLoopActive = false; |
|
} |
|
|
|
this.handleMessage(message, logCtx); |
|
} |
|
|
|
const durationMs = Date.now() - startTime; |
|
logCtx.agentCompleted({ |
|
status: "success", |
|
durationMs, |
|
}); |
|
runStatus = "completed"; |
|
|
|
} catch (error) { |
|
const durationMs = Date.now() - startTime; |
|
logCtx.agentCompleted({ |
|
status: "failure", |
|
durationMs, |
|
error: error instanceof Error ? error.message : String(error), |
|
}); |
|
runStatus = "failed"; |
|
throw error; |
|
} finally { |
|
clearInterval(statsInterval); |
|
this.isRunning = false; |
|
this.abortController = null; |
|
|
|
// Save final state with status |
|
this.saveState(runStatus); |
|
|
|
// Build and save stats |
|
const durationMs = Date.now() - this.runStartTime; |
|
const agentStats = this.manager.getGlobalStats(); |
|
const stats: RunStats = { |
|
sessionId: this.config.sessionId, |
|
runId: runId, |
|
startedAt: new Date(this.runStartTime).toISOString(), |
|
completedAt: nowISO(), |
|
durationMs, |
|
agents: { |
|
total: agentStats.active + agentStats.queued + agentStats.completed + agentStats.failed, |
|
completed: agentStats.completed, |
|
failed: agentStats.failed, |
|
cancelled: 0, // TODO: track cancelled separately |
|
}, |
|
tokens: this.tokenStats, |
|
costUsd: this.totalCostUsd, |
|
turns: this.totalTurns, |
|
}; |
|
|
|
await this.writeStats(stats); |
|
this.printFinalStats(stats); |
|
|
|
await this.cleanup(); |
|
} |
|
} |
|
|
|
/** |
|
* Write stats.json to the log directory |
|
*/ |
|
private async writeStats(stats: RunStats): Promise<void> { |
|
if (!this.dateDir || !this.sessionDir || !this.currentRunId) return; |
|
|
|
const statsPath = join( |
|
this.config.logBaseDir, |
|
this.dateDir, |
|
this.sessionDir, |
|
this.currentRunId, |
|
"stats.json" |
|
); |
|
|
|
try { |
|
mkdirSync(dirname(statsPath), { recursive: true }); |
|
writeFileSync(statsPath, JSON.stringify(stats, null, 2) + "\n"); |
|
} catch (err) { |
|
console.error(`Failed to write stats: ${err}`); |
|
} |
|
} |
|
|
|
/** |
|
* Print final stats summary |
|
*/ |
|
private printFinalStats(stats: RunStats): void { |
|
const { agents, tokens, durationMs, costUsd, turns } = stats; |
|
const totalTokens = tokens.input + tokens.output; |
|
|
|
console.log("\n" + "═".repeat(50)); |
|
console.log("📊 Run Summary"); |
|
console.log("─".repeat(50)); |
|
|
|
// Duration |
|
const seconds = Math.floor(durationMs / 1000); |
|
const minutes = Math.floor(seconds / 60); |
|
const hours = Math.floor(minutes / 60); |
|
let durationStr = ""; |
|
if (hours > 0) durationStr = `${hours}h ${minutes % 60}m ${seconds % 60}s`; |
|
else if (minutes > 0) durationStr = `${minutes}m ${seconds % 60}s`; |
|
else durationStr = `${seconds}s`; |
|
console.log(`Duration: ${durationStr}`); |
|
|
|
// Turns |
|
console.log(`Turns: ${turns}`); |
|
|
|
// Agents |
|
if (agents.total > 0) { |
|
console.log(`Agents: ${agents.completed} completed, ${agents.failed} failed (${agents.total} total)`); |
|
} |
|
|
|
// Tokens |
|
if (totalTokens > 0) { |
|
console.log(`Tokens: ${tokens.input.toLocaleString()} in, ${tokens.output.toLocaleString()} out`); |
|
if (tokens.cacheRead > 0) { |
|
console.log(`Cache: ${tokens.cacheRead.toLocaleString()} read, ${tokens.cacheWrite.toLocaleString()} write`); |
|
} |
|
} |
|
|
|
// Cost |
|
if (costUsd > 0) { |
|
console.log(`Cost: $${costUsd.toFixed(4)}`); |
|
} |
|
|
|
console.log("═".repeat(50) + "\n"); |
|
} |
|
|
|
/** |
|
* Stop the orchestrator gracefully |
|
*/ |
|
stop(): void { |
|
this.isRunning = false; |
|
this.abortController?.abort(); |
|
} |
|
|
|
/** |
|
* Cleanup resources |
|
*/ |
|
private async cleanup(): Promise<void> { |
|
this.manager.removeAllListeners("agentComplete"); |
|
this.manager.removeAllListeners("runComplete"); |
|
this.manager.removeAllListeners("stateChanged"); |
|
this.interactiveHandler.close(); |
|
try { |
|
await this.logger?.close(); |
|
} catch { |
|
// Ignore close errors |
|
} |
|
} |
|
|
|
// =========================================================================== |
|
// State Persistence for Resume |
|
// =========================================================================== |
|
|
|
/** |
|
* Get path to state.json |
|
*/ |
|
private getStatePath(): string | null { |
|
if (!this.dateDir || !this.sessionDir || !this.currentRunId) return null; |
|
return join(this.config.logBaseDir, this.dateDir, this.sessionDir, this.currentRunId, "state.json"); |
|
} |
|
|
|
/** |
|
* Build current resumable state |
|
*/ |
|
private buildState(status: "running" | "completed" | "failed"): ResumableState { |
|
const runs: Record<string, SavedRunState> = {}; |
|
|
|
// Get all runs from agent manager |
|
for (const [runId, runState] of this.manager.getAllRuns()) { |
|
const agents: Record<string, SavedAgentState> = {}; |
|
for (const [agentId, agentState] of runState.agents) { |
|
agents[agentId] = { |
|
id: agentState.id, |
|
name: agentState.name, |
|
itemId: agentState.itemId, |
|
itemIndex: agentState.itemIndex, |
|
status: agentState.status, |
|
priority: agentState.priority, |
|
depth: agentState.depth, |
|
parentAgentId: agentState.parentAgentId, |
|
result: agentState.result, |
|
error: agentState.error, |
|
fyiInjected: agentState.fyiInjected ?? false, |
|
startedAt: agentState.startedAt, |
|
completedAt: agentState.completedAt, |
|
}; |
|
} |
|
runs[runId] = { |
|
runId, |
|
context: runState.config.context, |
|
items: runState.config.items, |
|
options: runState.config.options, |
|
agents, |
|
startedAt: runState.startedAt, |
|
completedAt: runState.completedAt, |
|
}; |
|
} |
|
|
|
return { |
|
version: 1, |
|
sessionId: this.config.sessionId, |
|
dateDir: this.dateDir ?? "", |
|
sessionDir: this.sessionDir ?? "", |
|
runId: this.currentRunId ?? "", |
|
logDir: this.dateDir && this.sessionDir && this.currentRunId |
|
? join(this.config.logBaseDir, this.dateDir, this.sessionDir, this.currentRunId) |
|
: "", |
|
startedAt: new Date(this.runStartTime).toISOString(), |
|
lastUpdatedAt: nowISO(), |
|
status, |
|
promptHierarchy: this.promptHierarchy, |
|
runs, |
|
stats: { |
|
tokens: this.tokenStats, |
|
costUsd: this.totalCostUsd, |
|
turns: this.totalTurns, |
|
}, |
|
config: { |
|
model: this.config.model, |
|
maxConcurrent: this.config.maxConcurrent, |
|
defaultDepth: this.config.defaultDepth, |
|
}, |
|
}; |
|
} |
|
|
|
/** |
|
* Save current state to disk (atomic write) |
|
*/ |
|
private saveState(status: "running" | "completed" | "failed" = "running"): void { |
|
const statePath = this.getStatePath(); |
|
if (!statePath) return; |
|
|
|
try { |
|
const state = this.buildState(status); |
|
const tempPath = statePath + ".tmp"; |
|
mkdirSync(dirname(statePath), { recursive: true }); |
|
writeFileSync(tempPath, JSON.stringify(state, null, 2) + "\n"); |
|
// Atomic rename |
|
const fs = require("node:fs"); |
|
fs.renameSync(tempPath, statePath); |
|
} catch (err) { |
|
console.error(`Failed to save state: ${err}`); |
|
} |
|
} |
|
|
|
/** |
|
* Load state from a state.json file |
|
*/ |
|
static loadState(statePath: string): ResumableState | null { |
|
try { |
|
if (!existsSync(statePath)) return null; |
|
const content = readFileSync(statePath, "utf-8"); |
|
const state = JSON.parse(content) as ResumableState; |
|
if (state.version !== 1) { |
|
console.error(`Unsupported state version: ${state.version}`); |
|
return null; |
|
} |
|
return state; |
|
} catch (err) { |
|
console.error(`Failed to load state: ${err}`); |
|
return null; |
|
} |
|
} |
|
|
|
/** |
|
* Find a session by ID, path, or "latest" |
|
* Returns the path to state.json if found |
|
*/ |
|
static findSession(query: string, logBaseDir: string = DEFAULT_LOG_BASE): string | null { |
|
// If query is "latest", find the most recent session |
|
if (query === "latest") { |
|
return Orchestrator.findLatestSession(logBaseDir); |
|
} |
|
|
|
// If query is a full path to state.json |
|
if (query.endsWith("state.json") && existsSync(query)) { |
|
return query; |
|
} |
|
|
|
// If query is a path to a session/run directory |
|
const stateInDir = join(query, "state.json"); |
|
if (existsSync(stateInDir)) { |
|
return stateInDir; |
|
} |
|
|
|
// If query is a session ID (UUID), search for it |
|
// Look in all date directories |
|
if (!existsSync(logBaseDir)) return null; |
|
|
|
for (const dateDir of readdirSync(logBaseDir)) { |
|
const datePath = join(logBaseDir, dateDir); |
|
if (!existsSync(datePath)) continue; |
|
|
|
try { |
|
for (const sessionDir of readdirSync(datePath)) { |
|
// Check if this session dir contains the query (session ID) |
|
if (sessionDir.includes(query)) { |
|
const sessionPath = join(datePath, sessionDir); |
|
// Find any run directory inside |
|
for (const runDir of readdirSync(sessionPath)) { |
|
const statePath = join(sessionPath, runDir, "state.json"); |
|
if (existsSync(statePath)) { |
|
return statePath; |
|
} |
|
} |
|
} |
|
} |
|
} catch { |
|
// Skip directories we can't read |
|
} |
|
} |
|
|
|
return null; |
|
} |
|
|
|
/** |
|
* Find the most recent session |
|
*/ |
|
static findLatestSession(logBaseDir: string = DEFAULT_LOG_BASE): string | null { |
|
if (!existsSync(logBaseDir)) return null; |
|
|
|
let latestPath: string | null = null; |
|
let latestMtime = 0; |
|
|
|
try { |
|
for (const dateDir of readdirSync(logBaseDir).sort().reverse()) { |
|
const datePath = join(logBaseDir, dateDir); |
|
|
|
for (const sessionDir of readdirSync(datePath).sort().reverse()) { |
|
const sessionPath = join(datePath, sessionDir); |
|
|
|
for (const runDir of readdirSync(sessionPath)) { |
|
const statePath = join(sessionPath, runDir, "state.json"); |
|
if (existsSync(statePath)) { |
|
const stats = require("node:fs").statSync(statePath); |
|
if (stats.mtimeMs > latestMtime) { |
|
latestMtime = stats.mtimeMs; |
|
latestPath = statePath; |
|
} |
|
} |
|
} |
|
} |
|
|
|
// If we found something in the latest date dir, return it |
|
if (latestPath) return latestPath; |
|
} |
|
} catch { |
|
// Ignore errors |
|
} |
|
|
|
return latestPath; |
|
} |
|
|
|
/** |
|
* Resume from a saved state |
|
* |
|
* @param state - The loaded ResumableState |
|
* @param opts - Options to override (e.g., --concurrent from CLI) |
|
* @returns A configured Orchestrator ready to run |
|
*/ |
|
static async resume(state: ResumableState, opts: OrchestratorOptions = {}): Promise<Orchestrator> { |
|
// Create orchestrator with config from saved state |
|
const orchestrator = new Orchestrator({ |
|
sessionId: state.sessionId, |
|
logBaseDir: dirname(dirname(dirname(state.logDir))), // Strip runId/sessionDir/dateDir |
|
maxConcurrent: opts.maxConcurrent ?? state.config.maxConcurrent, |
|
defaultDepth: opts.defaultDepth ?? state.config.defaultDepth, |
|
model: opts.model ?? state.config.model, |
|
// Pass through other options |
|
permissionMode: opts.permissionMode, |
|
allowedTools: opts.allowedTools, |
|
disallowedTools: opts.disallowedTools, |
|
compactReminder: opts.compactReminder, |
|
debug: opts.debug, |
|
debugSdk: opts.debugSdk, |
|
interactive: opts.interactive, |
|
onAgentComplete: opts.onAgentComplete, |
|
onRunComplete: opts.onRunComplete, |
|
}); |
|
|
|
// Restore state fields |
|
orchestrator.dateDir = state.dateDir; |
|
orchestrator.sessionDir = state.sessionDir; |
|
orchestrator.currentRunId = state.runId; |
|
orchestrator.promptHierarchy = state.promptHierarchy; |
|
orchestrator.tokenStats = state.stats.tokens; |
|
orchestrator.totalCostUsd = state.stats.costUsd; |
|
orchestrator.totalTurns = state.stats.turns; |
|
orchestrator.isResuming = true; |
|
|
|
// Restore runs and re-queue pending agents |
|
orchestrator.restoreRuns(state); |
|
|
|
return orchestrator; |
|
} |
|
|
|
/** |
|
* Restore runs from saved state and re-queue pending agents |
|
*/ |
|
private restoreRuns(state: ResumableState): void { |
|
for (const [runId, savedRun] of Object.entries(state.runs)) { |
|
// Reconstruct items for pending agents (completed agents don't need re-running) |
|
const pendingItems: SpawnAgentsItem[] = []; |
|
const pendingAgentIds: string[] = []; |
|
|
|
for (const [agentId, savedAgent] of Object.entries(savedRun.agents)) { |
|
// Re-queue agents that were running or queued (interrupted) |
|
if (savedAgent.status === "running" || savedAgent.status === "queued") { |
|
// Get the original item from saved config |
|
const originalItem = savedRun.items[savedAgent.itemIndex]; |
|
if (originalItem) { |
|
pendingItems.push({ |
|
...originalItem, |
|
id: savedAgent.itemId, |
|
priority: savedAgent.priority, |
|
}); |
|
pendingAgentIds.push(agentId); |
|
} |
|
} |
|
|
|
// For completed agents whose FYI wasn't injected, queue FYI |
|
if (savedAgent.status === "completed" && !savedAgent.fyiInjected) { |
|
this.fyiQueue.push({ |
|
agentId: savedAgent.id, |
|
agentName: savedAgent.name, |
|
itemId: savedAgent.itemId, |
|
status: "completed", |
|
content: savedAgent.result ?? "", |
|
}); |
|
} |
|
|
|
// For failed agents whose FYI wasn't injected, queue FYI |
|
if (savedAgent.status === "failed" && !savedAgent.fyiInjected) { |
|
this.fyiQueue.push({ |
|
agentId: savedAgent.id, |
|
agentName: savedAgent.name, |
|
itemId: savedAgent.itemId, |
|
status: "failed", |
|
content: savedAgent.error ?? "Unknown error", |
|
}); |
|
} |
|
} |
|
|
|
// If there are pending items, re-spawn them |
|
if (pendingItems.length > 0) { |
|
console.log(`Re-queuing ${pendingItems.length} pending agents from run ${runId}`); |
|
this.manager.spawnAgents({ |
|
context: savedRun.context, |
|
items: pendingItems, |
|
options: savedRun.options, |
|
}); |
|
} |
|
} |
|
} |
|
|
|
/** Tools that are always auto-approved (our MCP tools + safe read-only tools) */ |
|
private static AUTO_APPROVED_TOOLS = new Set([ |
|
// Our MCP orchestration tools |
|
"SpawnAgents", |
|
"ListAgents", |
|
"WaitForRun", |
|
"CancelSubagents", |
|
"SetSubagentPriority", |
|
// Safe read-only tools |
|
"Read", |
|
"Glob", |
|
"Grep", |
|
"WebSearch", |
|
]); |
|
|
|
/** |
|
* Create a canUseTool callback for interactive permission handling with fusion. |
|
* |
|
* Permission fusion: If multiple agents request the same permission, |
|
* granting it once grants for all. Uses normalized cache keys to group |
|
* similar requests (e.g., all "npm" commands, all reads from same directory). |
|
*/ |
|
private createCanUseTool(): CanUseTool { |
|
const settings = getSettings(); |
|
const cwd = process.cwd(); |
|
|
|
return async (toolName: string, input: unknown, _context: unknown): Promise<PermissionResult> => { |
|
const inputObj = (input as Record<string, unknown>) ?? {}; |
|
|
|
// Helper to create allow result |
|
const allow = (): PermissionResult => ({ behavior: "allow", updatedInput: inputObj }); |
|
const deny = (message: string): PermissionResult => ({ behavior: "deny", message }); |
|
|
|
// Auto-approve our MCP tools and safe read-only tools |
|
if (Orchestrator.AUTO_APPROVED_TOOLS.has(toolName)) { |
|
return allow(); |
|
} |
|
|
|
// Also auto-approve MCP tools with mcp__ prefix (from MCP servers) |
|
if (toolName.startsWith("mcp__clorchestra__") || toolName.startsWith("mcp__clo__")) { |
|
return allow(); |
|
} |
|
|
|
// Build pattern for permission matching |
|
const inputStr = formatToolInput(input); |
|
const callPattern = inputStr ? `${toolName}(${inputStr})` : toolName; |
|
|
|
// Check persisted folder permissions first |
|
const persistedAction = settings.checkPermission(cwd, toolName, inputStr || undefined); |
|
if (persistedAction === "allow") { |
|
return allow(); |
|
} |
|
if (persistedAction === "deny") { |
|
return deny("Permission denied (folder settings)"); |
|
} |
|
|
|
// Check in-memory permission cache (permission fusion for this session) |
|
const cacheKey = permissionCacheKey(toolName, input); |
|
const cached = this.permissionCache.get(cacheKey); |
|
|
|
if (cached) { |
|
// Use cached decision |
|
cached.useCount++; |
|
if (cached.useCount > 1) { |
|
console.log(`🔗 Permission fused: "${toolName}"${inputStr ? `: ${inputStr}` : ""} (${cached.useCount} agents)`); |
|
} |
|
|
|
return cached.granted ? allow() : deny("Permission denied (cached)"); |
|
} |
|
|
|
// For interactive mode, ask user about permission |
|
if (this.config.interactive) { |
|
const answer = await this.interactiveHandler.askQuestion({ |
|
id: generateId("perm"), |
|
source: "main", |
|
text: `Allow tool "${toolName}"${inputStr ? `: ${inputStr}` : ""}?`, |
|
options: [ |
|
{ label: "Yes", description: "Allow this tool call" }, |
|
{ label: "No", description: "Deny this tool call" }, |
|
{ label: "Always (session)", description: "Always allow for this session" }, |
|
{ label: "Always (folder)", description: "Always allow in this folder (persisted)" }, |
|
{ label: "Never (folder)", description: "Always deny in this folder (persisted)" }, |
|
], |
|
}); |
|
|
|
const lowerAnswer = answer.toLowerCase(); |
|
let decision: PermissionDecision; |
|
|
|
if (lowerAnswer === "no") { |
|
decision = { granted: false, timestamp: nowISO(), useCount: 1 }; |
|
this.permissionCache.set(cacheKey, decision); |
|
return deny("User denied this tool call"); |
|
} |
|
if (lowerAnswer.includes("never")) { |
|
decision = { granted: false, behavior: "deny", timestamp: nowISO(), useCount: 1 }; |
|
this.permissionCache.set(cacheKey, decision); |
|
// Persist to folder settings |
|
settings.addPermission(cwd, callPattern, "deny"); |
|
saveSettings(); |
|
return deny("User denied this tool (always in folder)"); |
|
} |
|
if (lowerAnswer.includes("folder")) { |
|
decision = { granted: true, behavior: "allow", timestamp: nowISO(), useCount: 1 }; |
|
this.permissionCache.set(cacheKey, decision); |
|
// Persist to folder settings |
|
settings.addPermission(cwd, callPattern, "allow"); |
|
saveSettings(); |
|
return allow(); |
|
} |
|
if (lowerAnswer.includes("session")) { |
|
decision = { granted: true, behavior: "allow", timestamp: nowISO(), useCount: 1 }; |
|
this.permissionCache.set(cacheKey, decision); |
|
return allow(); |
|
} |
|
|
|
// Yes - cache the grant for this session |
|
decision = { granted: true, timestamp: nowISO(), useCount: 1 }; |
|
this.permissionCache.set(cacheKey, decision); |
|
return allow(); |
|
} |
|
|
|
// Non-interactive: auto-approve and cache |
|
const decision: PermissionDecision = { granted: true, timestamp: nowISO(), useCount: 1 }; |
|
this.permissionCache.set(cacheKey, decision); |
|
return allow(); |
|
}; |
|
} |
|
|
|
/** |
|
* Get permission cache statistics |
|
*/ |
|
getPermissionStats(): { total: number; fusedCount: number; decisions: Array<{ key: string; decision: PermissionDecision }> } { |
|
const decisions = Array.from(this.permissionCache.entries()).map(([key, decision]) => ({ key, decision })); |
|
const fusedCount = decisions.filter(d => d.decision.useCount > 1).length; |
|
return { total: decisions.length, fusedCount, decisions }; |
|
} |
|
|
|
/** |
|
* Create an async generator that yields the initial prompt |
|
* and FYI messages as they arrive. |
|
* |
|
* The generator terminates when isRunning becomes false. |
|
*/ |
|
private async *createPromptGenerator( |
|
initialPrompt: string |
|
): AsyncGenerator<SDKUserMessage, void, unknown> { |
|
// Yield the initial prompt if provided |
|
if (initialPrompt) { |
|
yield { |
|
type: "user" as const, |
|
message: { |
|
role: "user" as const, |
|
content: initialPrompt, |
|
}, |
|
parent_tool_use_id: null, |
|
session_id: this.config.sessionId, |
|
}; |
|
} |
|
|
|
// Check for FYI messages and compact reminders while running |
|
while (this.isRunning) { |
|
// Wait a bit before checking again |
|
await sleep(100); |
|
|
|
// Inject log info after session initialization |
|
if (this.pendingLogInfoInjection && this.isRunning) { |
|
this.pendingLogInfoInjection = false; |
|
const logInfo = this.formatLogInfo(); |
|
|
|
yield { |
|
type: "user" as const, |
|
message: { |
|
role: "user" as const, |
|
content: logInfo, |
|
}, |
|
parent_tool_use_id: null, |
|
isSynthetic: true, |
|
session_id: this.config.sessionId, |
|
}; |
|
} |
|
|
|
// Inject prompt hierarchy reminder after compact |
|
if (this.pendingCompactReminder && this.isRunning) { |
|
this.pendingCompactReminder = false; |
|
const reminder = this.formatCompactReminder(); |
|
|
|
yield { |
|
type: "user" as const, |
|
message: { |
|
role: "user" as const, |
|
content: reminder, |
|
}, |
|
parent_tool_use_id: null, |
|
isSynthetic: true, |
|
session_id: this.config.sessionId, |
|
}; |
|
} |
|
|
|
// Drain the FYI queue |
|
while (this.fyiQueue.length > 0 && this.isRunning) { |
|
const fyi = this.fyiQueue.shift(); |
|
if (!fyi) break; |
|
|
|
const fyiContent = this.formatFYI(fyi); |
|
|
|
yield { |
|
type: "user" as const, |
|
message: { |
|
role: "user" as const, |
|
content: fyiContent, |
|
}, |
|
parent_tool_use_id: null, |
|
isSynthetic: true, |
|
session_id: this.config.sessionId, |
|
}; |
|
} |
|
|
|
// Drain the user input queue (interactive chat) |
|
while (this.userInputQueue.length > 0 && this.isRunning) { |
|
const userMessage = this.userInputQueue.shift(); |
|
if (!userMessage) break; |
|
|
|
yield { |
|
type: "user" as const, |
|
message: { |
|
role: "user" as const, |
|
content: userMessage, |
|
}, |
|
parent_tool_use_id: null, |
|
session_id: this.config.sessionId, |
|
}; |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Format a reminder of the prompt hierarchy after compaction |
|
*/ |
|
private formatCompactReminder(): string { |
|
const lines = [ |
|
"📋 **CONTEXT REMINDER** (auto-injected after compaction)", |
|
"", |
|
"Your prompt hierarchy (stay focused on these objectives):", |
|
"", |
|
]; |
|
|
|
for (let i = 0; i < this.promptHierarchy.length; i++) { |
|
const indent = " ".repeat(i); |
|
const bullet = i === 0 ? "🎯" : "└─"; |
|
lines.push(`${indent}${bullet} ${this.promptHierarchy[i]}`); |
|
} |
|
|
|
lines.push(""); |
|
lines.push("Continue working on the current task. Don't restart from scratch."); |
|
|
|
return lines.join("\n"); |
|
} |
|
|
|
/** |
|
* Format log info message for the main agent |
|
*/ |
|
private formatLogInfo(): string { |
|
const fullSessionPath = this.dateDir && this.sessionDir |
|
? `${this.dateDir}/${this.sessionDir}` |
|
: this.config.sessionId; |
|
const mainLogFile = `${this.config.logBaseDir}/${fullSessionPath}/${this.currentRunId}/main.jsonl`; |
|
const agentsLogDir = `${this.config.logBaseDir}/${fullSessionPath}/${this.currentRunId}/agents/`; |
|
|
|
return [ |
|
"[Clorchestra Session Info]", |
|
"", |
|
`Session ID: ${this.config.sessionId}`, |
|
`Run ID: ${this.currentRunId}`, |
|
"", |
|
"Log Files:", |
|
` Your log: ${mainLogFile}`, |
|
` Subagent logs: ${agentsLogDir}<agent-id>.jsonl`, |
|
"", |
|
"Note: All logs are append-only JSONL with OpenTelemetry attributes.", |
|
].join("\n"); |
|
} |
|
|
|
/** |
|
* Format an FYI message for injection |
|
*/ |
|
private formatFYI(fyi: FYIMessage): string { |
|
const status = fyi.status === "completed" ? "✅" : "❌"; |
|
const timing = fyi.durationMs ? ` (${fyi.durationMs}ms)` : ""; |
|
const cost = fyi.costUsd ? ` $${fyi.costUsd.toFixed(4)}` : ""; |
|
|
|
let content = `[FYI: ${status} Agent "${fyi.agentName}" (item: ${fyi.itemId}) ${fyi.status}${timing}${cost}]\n\n`; |
|
content += fyi.content; |
|
|
|
return content; |
|
} |
|
|
|
/** |
|
* Handle an SDK message |
|
*/ |
|
private handleMessage(message: SDKMessage, logCtx: LoggerContext | DeferredLoggerContext): void { |
|
// Debug mode: show all SDK messages |
|
if (this.config.debug) { |
|
const debugMsg = JSON.stringify(message, null, 2); |
|
console.log(`\n[DEBUG] SDK message (${message.type}${(message as {subtype?: string}).subtype ? `/${(message as {subtype?: string}).subtype}` : ""}):`); |
|
console.log(debugMsg.length > 2000 ? debugMsg.substring(0, 2000) + "\n... [truncated]" : debugMsg); |
|
} |
|
|
|
switch (message.type) { |
|
case "system": |
|
if (message.subtype === "init") { |
|
const tools = message.tools || []; |
|
console.log(`✓ Model: ${message.model}`); |
|
console.log(`✓ Tools: ${tools.join(", ")}\n`); |
|
} else if (message.subtype === "compact_boundary") { |
|
// Detect compaction event |
|
const compactMsg = message as { compact_metadata?: { trigger: string; pre_tokens: number } }; |
|
const trigger = compactMsg.compact_metadata?.trigger ?? "unknown"; |
|
const preTokens = compactMsg.compact_metadata?.pre_tokens ?? 0; |
|
console.log(`\n📦 Context compacted (${trigger}, was ${preTokens} tokens)`); |
|
logCtx.info(`Compact event: ${trigger}, pre_tokens=${preTokens}`); |
|
|
|
// Set flag to inject prompt hierarchy reminder |
|
if (this.config.compactReminder) { |
|
this.pendingCompactReminder = true; |
|
} |
|
} |
|
logCtx.messageReceived({ messageType: "system", subtype: message.subtype }); |
|
break; |
|
|
|
case "assistant": |
|
logCtx.messageReceived({ messageType: "assistant" }); |
|
if (message.message?.content) { |
|
const textParts: string[] = []; |
|
for (const block of message.message.content) { |
|
if ("text" in block && block.text) { |
|
console.log(block.text); |
|
textParts.push(block.text); |
|
} else if ("type" in block && block.type === "tool_use") { |
|
const toolBlock = block as { id: string; name: string; input: unknown }; |
|
// Show tool name and arguments |
|
const inputStr = formatToolInput(toolBlock.input); |
|
console.log(`\n🔧 ${toolBlock.name}${inputStr ? `: ${inputStr}` : ""}`); |
|
textParts.push(`🔧 ${toolBlock.name}${inputStr ? `: ${inputStr}` : ""}`); |
|
logCtx.toolCalled({ |
|
toolName: toolBlock.name, |
|
callId: toolBlock.id, |
|
arguments: toolBlock.input, |
|
}); |
|
} |
|
} |
|
// Forward to UI |
|
if (textParts.length > 0 && this.config.interactive) { |
|
this.interactiveHandler.onMainMessage({ |
|
role: "assistant", |
|
content: textParts.join("\n"), |
|
timestamp: new Date().toISOString(), |
|
}); |
|
} |
|
} |
|
break; |
|
|
|
case "user": |
|
logCtx.messageReceived({ messageType: "user", subtype: message.isSynthetic ? "synthetic" : undefined }); |
|
// User messages are typically tool results or injected FYIs |
|
break; |
|
|
|
case "result": |
|
console.log("\n" + "─".repeat(50)); |
|
logCtx.messageReceived({ messageType: "result", subtype: message.subtype }); |
|
|
|
// Extract token usage from result message |
|
if ("usage" in message && message.usage) { |
|
const usage = message.usage as { input: number; output: number; cache_read: number; cache_write: number }; |
|
this.tokenStats.input += usage.input ?? 0; |
|
this.tokenStats.output += usage.output ?? 0; |
|
this.tokenStats.cacheRead += usage.cache_read ?? 0; |
|
this.tokenStats.cacheWrite += usage.cache_write ?? 0; |
|
} |
|
if ("total_cost_usd" in message) { |
|
this.totalCostUsd += message.total_cost_usd ?? 0; |
|
} |
|
if ("num_turns" in message) { |
|
this.totalTurns += message.num_turns ?? 0; |
|
} |
|
|
|
if (message.subtype === "success") { |
|
console.log(`\n✅ Done (${message.num_turns} turns, ${message.duration_ms}ms, $${message.total_cost_usd.toFixed(4)})`); |
|
if (message.result) { |
|
console.log(`\nResult:\n${message.result}`); |
|
} |
|
} else { |
|
console.log(`\n❌ ${message.subtype}`); |
|
// Show error details |
|
if ("errors" in message && message.errors) { |
|
console.log(`Errors: ${JSON.stringify(message.errors, null, 2)}`); |
|
} |
|
} |
|
break; |
|
|
|
default: |
|
// Log other message types in debug mode |
|
if (this.config.debug) { |
|
logCtx.messageReceived({ messageType: (message as {type: string}).type }); |
|
} |
|
break; |
|
} |
|
} |
|
|
|
/** |
|
* Get the agent manager for direct access |
|
*/ |
|
getManager(): AgentManager { |
|
return this.manager; |
|
} |
|
|
|
/** |
|
* Get the session ID |
|
*/ |
|
getSessionId(): string { |
|
return this.config.sessionId; |
|
} |
|
} |
|
|
|
// ============================================================================= |
|
// Utility |
|
// ============================================================================= |
|
|
|
function sleep(ms: number): Promise<void> { |
|
return new Promise(resolve => setTimeout(resolve, ms)); |
|
} |
|
|
|
/** |
|
* Format tool input for display in terminal |
|
*/ |
|
function formatToolInput(input: unknown): string { |
|
if (!input || typeof input !== "object") return ""; |
|
|
|
const obj = input as Record<string, unknown>; |
|
|
|
// Special formatting for common tools |
|
if ("command" in obj && typeof obj.command === "string") { |
|
// Bash tool - show command |
|
return obj.command; |
|
} |
|
if ("file_path" in obj && typeof obj.file_path === "string") { |
|
// Read/Edit tool - show path |
|
return obj.file_path; |
|
} |
|
if ("pattern" in obj && typeof obj.pattern === "string") { |
|
// Glob/Grep tool - show pattern |
|
const path = "path" in obj ? ` in ${obj.path}` : ""; |
|
return `${obj.pattern}${path}`; |
|
} |
|
if ("query" in obj && typeof obj.query === "string") { |
|
// WebSearch tool |
|
return obj.query; |
|
} |
|
if ("context" in obj && "items" in obj) { |
|
// SpawnAgents tool |
|
const items = obj.items as unknown[]; |
|
return `${items.length} agents`; |
|
} |
|
if ("runId" in obj && typeof obj.runId === "string") { |
|
// ListAgents/WaitForRun/etc |
|
return obj.runId; |
|
} |
|
if ("ids" in obj && Array.isArray(obj.ids)) { |
|
// CancelSubagents |
|
return `${obj.ids.length} agents`; |
|
} |
|
|
|
// Fallback: compact JSON (truncated) |
|
try { |
|
const json = JSON.stringify(obj); |
|
return json.length > 80 ? json.substring(0, 77) + "..." : json; |
|
} catch { |
|
return ""; |
|
} |
|
} |
|
|
|
// ============================================================================= |
|
// Simple Run Function |
|
// ============================================================================= |
|
|
|
export async function runOrchestrator(prompt: string, opts?: OrchestratorOptions): Promise<void> { |
|
const orchestrator = new Orchestrator(opts); |
|
await orchestrator.run(prompt); |
|
} |