Skip to content

Instantly share code, notes, and snippets.

@ochafik
Last active December 15, 2025 19:41
Show Gist options
  • Select an option

  • Save ochafik/565c10cee64cd908938485a0b85f86a1 to your computer and use it in GitHub Desktop.

Select an option

Save ochafik/565c10cee64cd908938485a0b85f86a1 to your computer and use it in GitHub Desktop.
Clorchestra - Claude Agent SDK coordinator CLI

Clo

Map-reduce agent orchestration for the Claude Agent SDK. Spawn parallel agents with priority queues, retries, and hierarchical navigation.

Quick Start

# Add alias to your shell profile (~/.bashrc, ~/.zshrc, etc.)
alias clo='npx -y https://gist.github.com/ochafik/565c10cee64cd908938485a0b85f86a1'

# Then use it
clo "your prompt here"

Features

  • Parallel Agents: Spawn N agents with shared context + per-item prompts
  • Priority Queue: Lower number = higher priority, FIFO within priority
  • Retries: Exponential backoff with jitter (default 3 retries)
  • Timeouts: Per-agent timeouts (default 5 min)
  • Hierarchical Navigation: Tree view of nested agents with breadcrumb trail
  • Interactive TUI: Multi-view chat UI with agent list navigation
  • Resume: Continue interrupted sessions with --resume
  • JSONL Logging: OpenTelemetry-style logs in ~/.clo/runs/
  • Permission Fusion: Cache permission decisions across similar requests

CLI Options

--session <id>         Session identifier
--concurrent <n>       Max concurrent agents (default: 10)
--depth <n>            Agent depth: > 0 allows subagents (default: 1)
--model <model>        Model: sonnet, opus, haiku (default: sonnet)
--resume <session>     Resume session (ID, path, or "latest")
--permission-mode      Permission mode: default, acceptEdits, plan, bypassPermissions
--allowedTools <t>     Comma-separated allowed tools
--disallowedTools <t>  Comma-separated disallowed tools
--debug                Enable SDK debug output
--debug-clo            Enable verbose orchestrator output
--no-compact-reminder  Disable hierarchy reminder after compaction

MCP Tools

Agents have access to these tools:

Tool Description
SpawnAgents Spawn N agents in parallel with context + items array
ListAgents List agents with status/queue filters
WaitForRun Block until all agents complete
CancelSubagents Cancel agents by ID
SetSubagentPriority Change priority of queued agents

Interactive Mode

  • Down Arrow: Open agent tree picker (from main or agent view)
  • Enter: Select agent to view/chat
  • Ctrl+C: Cascading exit (clear input → back → quit)
  • Esc: Back to previous view

Footer shows:

  • Elapsed time, active/queued/done/failed counts, tokens, cost
  • Breadcrumb trail when viewing sub-agents: main › parent › child

Example

# Interactive mode (no prompt)
clo

# Run with a prompt
clo "Analyze all TypeScript files in src/"

# Resume last session
clo --resume latest

# Use haiku model with higher concurrency
clo --model haiku --concurrent 20 "Process these items"

Logs

Logs are written to ~/.clo/runs/YYYY-MM-DD/YYYY-MM-DD-HHmm-<session-id>/:

  • state.json - Resumable state
  • stats.json - Final run statistics
  • main.jsonl - Orchestrator logs
  • agents/*.jsonl - Per-agent logs

License

MIT

/**
* Agent Manager - manages the lifecycle of spawned agents with priority queue
*
* Features:
* - Priority queue (lower number = higher priority)
* - Concurrency control (default: 10)
* - Retries with exponential backoff + jitter
* - Per-agent timeouts
* - Cancel by ID
* - Dynamic priority updates
*/
import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { EventEmitter } from "node:events";
import type {
AgentState,
AgentStatus,
RunState,
SpawnAgentsConfig,
SpawnAgentsItem,
SpawnAgentsOptions,
FYIMessage,
RetryConfig,
CancelSubagentsResult,
SetSubagentPriorityResult,
} from "./types.js";
import {
generateId,
generateTraceId,
nowISO,
calculateRetryDelay,
isRetryableError,
DEFAULT_MAX_CONCURRENT,
DEFAULT_AGENT_DEPTH,
DEFAULT_LOG_BASE,
DEFAULT_PRIORITY,
DEFAULT_TIMEOUT_MS,
DEFAULT_RETRY_CONFIG,
} from "./types.js";
import { JSONLLogger, LoggerContext, createAgentLogger } from "./logging.js";
// =============================================================================
// Priority Queue
// =============================================================================
interface QueuedAgent {
agentId: string;
runId: string;
priority: number;
queuedAt: number;
}
class PriorityQueue {
private heap: QueuedAgent[] = [];
get length(): number {
return this.heap.length;
}
push(item: QueuedAgent): void {
this.heap.push(item);
this.bubbleUp(this.heap.length - 1);
}
pop(): QueuedAgent | undefined {
if (this.heap.length === 0) return undefined;
const top = this.heap[0];
const last = this.heap.pop()!;
if (this.heap.length > 0) {
this.heap[0] = last;
this.bubbleDown(0);
}
return top;
}
peek(): QueuedAgent | undefined {
return this.heap[0];
}
remove(agentId: string): boolean {
const idx = this.heap.findIndex(item => item.agentId === agentId);
if (idx === -1) return false;
// If removing the last element, just pop
if (idx === this.heap.length - 1) {
this.heap.pop();
return true;
}
// Move last element to removed position
const last = this.heap.pop()!;
this.heap[idx] = last;
// Only bubble in one direction based on comparison with parent
if (idx > 0 && this.compare(this.heap[idx], this.heap[Math.floor((idx - 1) / 2)]) < 0) {
this.bubbleUp(idx);
} else {
this.bubbleDown(idx);
}
return true;
}
updatePriority(agentId: string, newPriority: number): boolean {
const idx = this.heap.findIndex(item => item.agentId === agentId);
if (idx === -1) return false;
const oldPriority = this.heap[idx].priority;
this.heap[idx].priority = newPriority;
// Bubble in correct direction based on priority change
if (newPriority < oldPriority) {
this.bubbleUp(idx);
} else if (newPriority > oldPriority) {
this.bubbleDown(idx);
}
return true;
}
getPosition(agentId: string): number | undefined {
// Return 1-based position in sorted order
const sorted = [...this.heap].sort((a, b) => this.compare(a, b));
const idx = sorted.findIndex(item => item.agentId === agentId);
return idx === -1 ? undefined : idx + 1;
}
private compare(a: QueuedAgent, b: QueuedAgent): number {
// Lower priority number = higher priority (runs first)
if (a.priority !== b.priority) return a.priority - b.priority;
// Same priority: earlier queued runs first (FIFO within priority)
return a.queuedAt - b.queuedAt;
}
private bubbleUp(idx: number): void {
while (idx > 0) {
const parent = Math.floor((idx - 1) / 2);
if (this.compare(this.heap[idx], this.heap[parent]) >= 0) break;
[this.heap[idx], this.heap[parent]] = [this.heap[parent], this.heap[idx]];
idx = parent;
}
}
private bubbleDown(idx: number): void {
while (true) {
const left = 2 * idx + 1;
const right = 2 * idx + 2;
let smallest = idx;
if (left < this.heap.length && this.compare(this.heap[left], this.heap[smallest]) < 0) {
smallest = left;
}
if (right < this.heap.length && this.compare(this.heap[right], this.heap[smallest]) < 0) {
smallest = right;
}
if (smallest === idx) break;
[this.heap[idx], this.heap[smallest]] = [this.heap[smallest], this.heap[idx]];
idx = smallest;
}
}
}
// =============================================================================
// Agent Manager
// =============================================================================
export interface AgentManagerOptions {
sessionId: string;
logBaseDir: string;
maxConcurrent: number;
defaultDepth: number;
defaultPriority: number;
defaultTimeoutMs: number;
defaultRetryConfig: RetryConfig;
defaultModel?: "sonnet" | "opus" | "haiku";
parentTraceId?: string;
}
export class AgentManager extends EventEmitter {
private opts: AgentManagerOptions;
private runs: Map<string, RunState> = new Map();
private queue = new PriorityQueue();
private activeAgents = 0;
private abortControllers: Map<string, AbortController> = new Map();
private agentInputQueues: Map<string, string[]> = new Map(); // For interactive agent chat
constructor(opts: Partial<AgentManagerOptions> & { sessionId: string }) {
super();
this.opts = {
sessionId: opts.sessionId,
logBaseDir: opts.logBaseDir ?? DEFAULT_LOG_BASE,
maxConcurrent: opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT,
defaultDepth: opts.defaultDepth ?? DEFAULT_AGENT_DEPTH,
defaultPriority: opts.defaultPriority ?? DEFAULT_PRIORITY,
defaultTimeoutMs: opts.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS,
defaultRetryConfig: opts.defaultRetryConfig ?? DEFAULT_RETRY_CONFIG,
defaultModel: opts.defaultModel, // Use SDK default if not specified
parentTraceId: opts.parentTraceId,
};
}
/**
* Update the session ID (called once we get the real ID from SDK)
*/
setSessionId(sessionId: string): void {
this.opts.sessionId = sessionId;
}
/**
* Spawn agents for a map-reduce operation
*/
async spawnAgents(config: SpawnAgentsConfig): Promise<RunState> {
const runId = generateId("run");
const depth = config.options?.depth ?? this.opts.defaultDepth;
const defaultPriority = config.options?.priority ?? this.opts.defaultPriority;
const defaultTimeout = config.options?.timeoutMs ?? this.opts.defaultTimeoutMs;
const defaultRetry = config.options?.retryConfig ?? this.opts.defaultRetryConfig;
const runState: RunState = {
runId,
sessionId: this.opts.sessionId,
logDir: `${this.opts.logBaseDir}/${this.opts.sessionId}/${runId}`,
agents: new Map(),
startedAt: nowISO(),
config,
};
const agentIds: string[] = [];
// Create agent states and add to queue
for (let i = 0; i < config.items.length; i++) {
const item = config.items[i];
const agentId = generateId("agent");
const itemId = item.id ?? generateId("item");
const priority = item.priority ?? defaultPriority;
const timeoutMs = item.timeoutMs ?? defaultTimeout;
const retryConfig = item.retryConfig ?? defaultRetry;
const agentState: AgentState = {
id: agentId,
name: `agent-${itemId}`,
itemId,
itemIndex: i,
status: "queued",
logFile: `${runState.logDir}/agents/${agentId}.jsonl`,
depth,
parentAgentId: config.options?.parentAgentId,
priority,
timeoutMs,
retryConfig,
retryAttempt: 0,
queuedAt: nowISO(),
};
runState.agents.set(agentId, agentState);
agentIds.push(agentId);
// Add to priority queue
this.queue.push({
agentId,
runId,
priority,
queuedAt: Date.now(),
});
}
this.runs.set(runId, runState);
// Update queue positions
this.updateQueuePositions(runId);
// Start processing queue
this.processQueue();
// Notify state change for persistence
this.emitStateChanged();
return runState;
}
/**
* Process the priority queue
*/
private processQueue(): void {
while (this.activeAgents < this.opts.maxConcurrent && this.queue.length > 0) {
const next = this.queue.pop();
if (!next) break;
const runState = this.runs.get(next.runId);
if (!runState) continue;
const agentState = runState.agents.get(next.agentId);
if (!agentState || agentState.status !== "queued") continue;
// Get the item by index (reliable lookup)
const item = runState.config.items[agentState.itemIndex];
if (!item) {
console.error(`[INTERNAL ERROR] Agent ${agentState.id} has invalid itemIndex ${agentState.itemIndex}`);
continue;
}
this.activeAgents++;
this.runAgent(next.runId, next.agentId, item, runState.config.context, runState.config.options)
.finally(() => {
this.activeAgents--;
this.processQueue();
});
}
}
/**
* Update queue positions for all agents in a run
*/
private updateQueuePositions(runId: string): void {
const runState = this.runs.get(runId);
if (!runState) return;
for (const [agentId, agent] of runState.agents) {
if (agent.status === "queued") {
agent.queuePosition = this.queue.getPosition(agentId);
} else {
agent.queuePosition = undefined;
}
}
}
/**
* Run a single agent with retry logic
*/
private async runAgent(
runId: string,
agentId: string,
item: SpawnAgentsItem,
context: string,
options?: SpawnAgentsOptions
): Promise<void> {
const runState = this.runs.get(runId);
if (!runState) return;
const agentState = runState.agents.get(agentId);
if (!agentState) return;
// Check if cancelled before starting
if (agentState.status === "cancelled") {
this.emitFYI(agentState, "cancelled", "Agent was cancelled before starting");
this.checkRunComplete(runId);
return;
}
agentState.status = "running";
agentState.startedAt = nowISO();
agentState.queuePosition = undefined;
// Create input queue for interactive chat
this.agentInputQueues.set(agentId, []);
const abortController = new AbortController();
this.abortControllers.set(agentId, abortController);
// Set up timeout
let timeoutId: NodeJS.Timeout | undefined;
if (agentState.timeoutMs) {
timeoutId = setTimeout(() => {
abortController.abort();
}, agentState.timeoutMs);
}
const logger = createAgentLogger(
this.opts.sessionId, runId, agentId, this.opts.logBaseDir,
{ traceId: this.opts.parentTraceId }
);
const logCtx = logger.child({ agentId, agentName: agentState.name });
logCtx.agentCreated({
itemId: agentState.itemId,
provider: "anthropic",
model: options?.model ?? "sonnet",
depth: agentState.depth,
context,
});
const startTime = Date.now();
try {
const parentLogFile = `${runState.logDir}/main.jsonl`;
await this.executeAgent(agentState, item, context, options, logCtx, abortController.signal, parentLogFile);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
agentState.lastError = error.message;
// Check if we should retry
const retryConfig = agentState.retryConfig ?? this.opts.defaultRetryConfig;
const canRetry = isRetryableError(error) && agentState.retryAttempt < retryConfig.maxRetries;
if (canRetry && agentState.status !== "cancelled") {
agentState.retryAttempt++;
const delay = calculateRetryDelay(agentState.retryAttempt, retryConfig);
logCtx.info(`Retrying agent (attempt ${agentState.retryAttempt}/${retryConfig.maxRetries}) after ${delay}ms`);
// Clear timeout and abort controller
if (timeoutId) clearTimeout(timeoutId);
this.abortControllers.delete(agentId);
// Re-queue with same priority
agentState.status = "queued";
agentState.queuedAt = nowISO();
setTimeout(() => {
this.queue.push({
agentId,
runId,
priority: agentState.priority,
queuedAt: Date.now(),
});
this.updateQueuePositions(runId);
this.processQueue();
}, delay);
await logger.close();
return;
}
// Final failure
agentState.status = "failed";
agentState.error = error.message;
logCtx.error("Agent execution failed", error);
}
const durationMs = Date.now() - startTime;
agentState.completedAt = nowISO();
agentState.durationMs = durationMs;
// Clear timeout
if (timeoutId) clearTimeout(timeoutId);
this.abortControllers.delete(agentId);
logCtx.agentCompleted({
status: agentState.status === "completed" ? "success" : "failure",
durationMs,
inputTokens: agentState.usage?.inputTokens,
outputTokens: agentState.usage?.outputTokens,
costUsd: agentState.costUsd,
numTurns: agentState.turns,
result: agentState.result,
error: agentState.error,
});
// Clean up resources
this.agentInputQueues.delete(agentState.id);
this.abortControllers.delete(agentState.id);
await logger.close();
// Emit FYI
this.emitFYI(
agentState,
agentState.status === "completed" ? "completed" : "failed",
agentState.status === "completed" ? (agentState.result ?? "") : (agentState.error ?? "Unknown error")
);
// Notify state change for persistence
this.emitStateChanged();
this.checkRunComplete(runId);
}
/**
* Execute the agent query
*/
private async executeAgent(
agentState: AgentState,
item: SpawnAgentsItem,
context: string,
options: SpawnAgentsOptions | undefined,
logCtx: LoggerContext,
signal: AbortSignal,
parentLogFile: string
): Promise<void> {
const prompt = this.buildPrompt(context, item, agentState.id, agentState.logFile, parentLogFile);
logCtx.agentInvoked({ prompt });
const allowedTools = options?.allowedTools ?? ["Read", "Edit", "Glob", "Grep", "Bash"];
const messages = query({
prompt,
options: {
model: options?.model ?? this.opts.defaultModel, // Use orchestrator's model if not specified
allowedTools,
disallowedTools: options?.disallowedTools,
maxTurns: options?.maxTurns ?? 50,
maxBudgetUsd: options?.maxBudgetUsd,
permissionMode: "acceptEdits",
abortController: { signal } as AbortController,
},
});
let totalIn = 0, totalOut = 0;
for await (const msg of messages) {
// Check for abort
if (signal.aborted) {
throw new Error("Agent execution timed out");
}
this.logMessage(msg, logCtx);
if (msg.type === "result") {
if (msg.subtype === "success") {
agentState.status = "completed";
agentState.result = msg.result ?? "Completed successfully";
agentState.turns = msg.num_turns;
agentState.costUsd = msg.total_cost_usd;
if (msg.modelUsage) {
for (const u of Object.values(msg.modelUsage)) {
totalIn += u.inputTokens ?? 0;
totalOut += u.outputTokens ?? 0;
}
}
agentState.usage = { inputTokens: totalIn, outputTokens: totalOut };
} else {
throw new Error(`Agent ended with status: ${msg.subtype}`);
}
}
}
}
/**
* Emit FYI message
*/
private emitFYI(agentState: AgentState, status: "completed" | "failed" | "cancelled", content: string): void {
const fyi: FYIMessage = {
agentId: agentState.id,
agentName: agentState.name,
itemId: agentState.itemId,
status,
content,
durationMs: agentState.durationMs,
costUsd: agentState.costUsd,
turns: agentState.turns,
retryAttempt: agentState.retryAttempt > 0 ? agentState.retryAttempt : undefined,
};
this.emit("agentComplete", fyi);
}
/**
* Emit state change notification (for persistence)
*/
private emitStateChanged(): void {
this.emit("stateChanged");
}
/**
* Cancel specific agents
*/
cancelAgents(ids: string[]): CancelSubagentsResult {
const result: CancelSubagentsResult = {
cancelled: [],
notFound: [],
alreadyFinished: [],
};
for (const id of ids) {
let found = false;
for (const run of this.runs.values()) {
const agent = run.agents.get(id);
if (agent) {
found = true;
if (agent.status === "completed" || agent.status === "failed" || agent.status === "cancelled") {
result.alreadyFinished.push(id);
} else {
// Cancel the agent
agent.status = "cancelled";
agent.completedAt = nowISO();
agent.error = "Cancelled by user";
// Remove from queue if queued
this.queue.remove(id);
// Abort if running
const controller = this.abortControllers.get(id);
if (controller) {
controller.abort();
this.abortControllers.delete(id);
}
result.cancelled.push(id);
// Emit FYI
this.emitFYI(agent, "cancelled", "Agent was cancelled");
// Notify state change for persistence
this.emitStateChanged();
this.checkRunComplete(run.runId);
}
break;
}
}
if (!found) {
result.notFound.push(id);
}
}
return result;
}
/**
* Set priority for an agent
*/
setAgentPriority(id: string, priority: number): SetSubagentPriorityResult | null {
for (const run of this.runs.values()) {
const agent = run.agents.get(id);
if (agent) {
const oldPriority = agent.priority;
agent.priority = priority;
let newQueuePosition: number | undefined;
if (agent.status === "queued") {
this.queue.updatePriority(id, priority);
this.updateQueuePositions(run.runId);
newQueuePosition = agent.queuePosition;
}
return { id, oldPriority, newPriority: priority, newQueuePosition };
}
}
return null;
}
/**
* Build the full prompt for an agent, including log info
*/
private buildPrompt(
context: string,
item: SpawnAgentsItem,
agentId: string,
agentLogFile: string,
parentLogFile: string
): string {
// Build the agent info header
const agentInfo = [
"<agent-info>",
`Agent ID: ${agentId}`,
`Your log file: ${agentLogFile}`,
`Parent log file: ${parentLogFile}`,
"Note: Logs are append-only JSONL with OpenTelemetry attributes.",
"When spawning subagents, pass your Agent ID as options.parentAgentId for hierarchy tracking.",
"</agent-info>",
"",
].join("\n");
let prompt = agentInfo + item.prompt;
if (item.data) {
prompt += `\n\n## Data\n\`\`\`json\n${JSON.stringify(item.data, null, 2)}\n\`\`\``;
}
return prompt;
}
// Note: SDK accepts 'sonnet' | 'opus' | 'haiku' directly, no mapping needed
/**
* Log an SDK message
*/
private logMessage(msg: SDKMessage, logCtx: LoggerContext): void {
if (msg.type === "system") {
logCtx.messageReceived({ messageType: "system", subtype: msg.subtype });
} else if (msg.type === "assistant") {
logCtx.messageReceived({ messageType: "assistant" });
if (msg.message?.content) {
for (const block of msg.message.content) {
if ("type" in block && block.type === "tool_use") {
const tb = block as { id: string; name: string; input: unknown };
logCtx.toolCalled({ toolName: tb.name, callId: tb.id, arguments: tb.input });
}
}
}
} else if (msg.type === "user") {
logCtx.messageReceived({ messageType: "user", subtype: msg.isSynthetic ? "synthetic" : undefined });
} else if (msg.type === "result") {
logCtx.messageReceived({ messageType: "result", subtype: msg.subtype });
}
}
/**
* Check if all agents in a run are complete
*/
private checkRunComplete(runId: string): void {
const run = this.runs.get(runId);
if (!run) return;
const allDone = [...run.agents.values()].every(
a => a.status === "completed" || a.status === "failed" || a.status === "cancelled"
);
if (allDone) {
run.completedAt = nowISO();
this.emit("runComplete", runId);
}
}
/**
* Get run status
*/
getRunStatus(runId: string): RunState | undefined {
return this.runs.get(runId);
}
/**
* Get run summary
*/
getRunSummary(runId: string) {
const run = this.runs.get(runId);
if (!run) return undefined;
const agents = [...run.agents.values()];
return {
total: agents.length,
queued: agents.filter(a => a.status === "queued").length,
running: agents.filter(a => a.status === "running").length,
completed: agents.filter(a => a.status === "completed").length,
failed: agents.filter(a => a.status === "failed").length,
cancelled: agents.filter(a => a.status === "cancelled").length,
};
}
/**
* Get aggregate stats across all runs
*/
getGlobalStats(): { active: number; queued: number; completed: number; failed: number } {
let active = 0, queued = 0, completed = 0, failed = 0;
for (const run of this.runs.values()) {
for (const agent of run.agents.values()) {
switch (agent.status) {
case "running": active++; break;
case "queued": queued++; break;
case "completed": completed++; break;
case "failed":
case "cancelled": failed++; break;
}
}
}
return { active, queued, completed, failed };
}
/**
* Get all runs (for state persistence)
*/
getAllRuns(): Map<string, RunState> {
return this.runs;
}
/**
* Get all agent infos for UI display (includes hierarchy info)
*/
getAllAgentInfos(): Array<{ id: string; name: string; status: AgentStatus; parentAgentId?: string; depth: number }> {
const agents: Array<{ id: string; name: string; status: AgentStatus; parentAgentId?: string; depth: number }> = [];
for (const run of this.runs.values()) {
for (const agent of run.agents.values()) {
agents.push({
id: agent.id,
name: agent.name,
status: agent.status,
parentAgentId: agent.parentAgentId,
depth: agent.depth,
});
}
}
return agents;
}
/**
* Send a message to a specific agent (for interactive chat)
* Returns true if the agent was found and message was queued
*/
sendToAgent(agentId: string, message: string): boolean {
// Find the agent's input queue
const queue = this.agentInputQueues.get(agentId);
if (queue) {
queue.push(message);
return true;
}
return false;
}
/**
* Get agents with optional filtering
*/
getAgents(runId: string, opts?: { queuedOnly?: boolean; status?: AgentStatus }): AgentState[] {
const run = this.runs.get(runId);
if (!run) return [];
let agents = [...run.agents.values()];
if (opts?.queuedOnly) {
agents = agents.filter(a => a.status === "queued");
} else if (opts?.status) {
agents = agents.filter(a => a.status === opts.status);
}
// Sort by queue position for queued agents
return agents.sort((a, b) => {
if (a.status === "queued" && b.status === "queued") {
return (a.queuePosition ?? 0) - (b.queuePosition ?? 0);
}
return 0;
});
}
/**
* Wait for all agents in a run to complete
*/
async waitForRun(runId: string): Promise<RunState | undefined> {
const run = this.runs.get(runId);
if (!run) return undefined;
const allDone = [...run.agents.values()].every(
a => a.status === "completed" || a.status === "failed" || a.status === "cancelled"
);
if (allDone) return run;
return new Promise(resolve => {
const handler = (id: string) => {
if (id === runId) {
this.off("runComplete", handler);
resolve(this.runs.get(runId));
}
};
this.on("runComplete", handler);
});
}
/**
* Cancel all agents in a run
*/
cancelRun(runId: string): CancelSubagentsResult {
const run = this.runs.get(runId);
if (!run) return { cancelled: [], notFound: [runId], alreadyFinished: [] };
const ids = [...run.agents.keys()];
return this.cancelAgents(ids);
}
}

Clo - Development Notes

Git Workflow

This is a gist - commit directly to main, no branches.

git add <files>
git commit -m "message"
git push origin main

NPM Configuration

Always use the public npm registry for npm commands:

npm install --registry=https://registry.npmjs.org/
npm audit --registry=https://registry.npmjs.org/

Project Structure

Flat directory (no src/ - this is a gist-style project):

  • cli.ts - Entry point
  • types.ts - Core types and constants
  • logging.ts - JSONL logging with OpenTelemetry conventions
  • agent-manager.ts - Agent lifecycle management with priority queue
  • mcp-tools.ts - MCP tools (SpawnAgents, ListAgents, CancelSubagents, etc.)
  • orchestrator.ts - Main loop with FYI injection

Key Configuration

  • Logs: ~/.clo/runs/<session-id>/
  • Default concurrency: 10
  • Default agent depth: 1 (can spawn subagents)
  • Default priority: 10 (lower = runs first, 0 = highest)
  • Default timeout: 5 minutes per agent
  • Default retries: 3 with exponential backoff + jitter

MCP Tools

Tool Description
SpawnAgents Spawn N agents in parallel (map-reduce)
ListAgents List agent status (supports queuedOnly filter)
WaitForRun Block until all agents complete
CancelSubagents Cancel agents by ID
SetSubagentPriority Change priority of queued agents

Agent Status Flow

queued → running → completed | failed | cancelled
                ↑
                └── (retry on transient error)

Build

npm run build  # Uses bun to bundle
bun run cli.ts  # Direct execution
#!/usr/bin/env node
/**
* Clo CLI - Claude Agent Orchestrator with Map-Reduce Pattern
*
* Supports same CLI args as Claude CLI for permissions, model selection, etc.
*/
import { runOrchestrator, Orchestrator } from "./orchestrator.js";
import type { PermissionMode } from "./types.js";
import { getSettings, saveSettings } from "./settings.js";
const VALID_PERMISSION_MODES = ["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk"];
function parsePositiveInt(value: string, name: string): number {
const num = parseInt(value, 10);
if (isNaN(num) || num < 1) {
console.error(`Error: ${name} must be a positive integer, got: ${value}`);
process.exit(1);
}
return num;
}
function parseNonNegativeInt(value: string, name: string): number {
const num = parseInt(value, 10);
if (isNaN(num) || num < 0) {
console.error(`Error: ${name} must be a non-negative integer, got: ${value}`);
process.exit(1);
}
return num;
}
function requireArgValue(args: string[], i: number, name: string): string {
const next = args[i + 1];
if (!next || next.startsWith("-")) {
console.error(`Error: ${name} requires a value`);
process.exit(1);
}
return next;
}
let orchestrator: Orchestrator | null = null;
async function main() {
const args = process.argv.slice(2);
// Parse flags
let sessionId: string | undefined;
let resumeSession: string | undefined; // Session ID, path, or "latest" to resume
let maxConcurrent: number | undefined;
let depth: number | undefined;
let permissionMode: PermissionMode | undefined;
let model: string | undefined;
let allowedTools: string[] | undefined;
let disallowedTools: string[] | undefined;
let compactReminder: boolean | undefined;
let debugClorchestra = false;
let debugSdk = false;
const promptParts: string[] = [];
let endOfFlags = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
// Support -- separator
if (arg === "--" && !endOfFlags) {
endOfFlags = true;
continue;
}
if (endOfFlags || !arg.startsWith("-")) {
promptParts.push(arg);
continue;
}
if (arg === "--session") {
sessionId = requireArgValue(args, i, "--session");
i++;
} else if (arg === "--resume") {
resumeSession = requireArgValue(args, i, "--resume");
i++;
} else if (arg === "--concurrent") {
maxConcurrent = parsePositiveInt(requireArgValue(args, i, "--concurrent"), "--concurrent");
i++;
} else if (arg === "--depth") {
depth = parseNonNegativeInt(requireArgValue(args, i, "--depth"), "--depth");
i++;
} else if (arg === "--permission-mode") {
const mode = requireArgValue(args, i, "--permission-mode");
if (!VALID_PERMISSION_MODES.includes(mode)) {
console.error(`Error: Invalid permission mode "${mode}". Valid: ${VALID_PERMISSION_MODES.join(", ")}`);
process.exit(1);
}
permissionMode = mode as PermissionMode;
i++;
} else if (arg === "--dangerously-skip-permissions") {
permissionMode = "bypassPermissions";
} else if (arg === "--model") {
model = requireArgValue(args, i, "--model");
i++;
} else if (arg === "--allowedTools") {
const value = requireArgValue(args, i, "--allowedTools");
allowedTools = value.split(",").map(t => t.trim()).filter(t => t.length > 0);
if (allowedTools.length === 0) {
console.error("Error: --allowedTools requires at least one tool name");
process.exit(1);
}
i++;
} else if (arg === "--disallowedTools") {
const value = requireArgValue(args, i, "--disallowedTools");
disallowedTools = value.split(",").map(t => t.trim()).filter(t => t.length > 0);
i++;
} else if (arg === "--no-compact-reminder") {
compactReminder = false;
} else if (arg === "--debug") {
debugSdk = true;
} else if (arg === "--debug-clo") {
debugClorchestra = true;
} else if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
} else {
console.error(`Error: Unknown option: ${arg}`);
process.exit(1);
}
}
const prompt = promptParts.join(" ");
// Handle resume mode
if (resumeSession) {
const statePath = Orchestrator.findSession(resumeSession);
if (!statePath) {
console.error(`Error: Could not find session to resume: ${resumeSession}`);
console.error("Use a session ID, path to session directory, or 'latest'");
process.exit(1);
}
const state = Orchestrator.loadState(statePath);
if (!state) {
console.error(`Error: Failed to load state from: ${statePath}`);
process.exit(1);
}
console.log(`\n🔄 Resuming session: ${state.sessionId}`);
console.log(` Status: ${state.status}`);
console.log(` Runs: ${Object.keys(state.runs).length}`);
// Count pending agents
let pendingAgents = 0;
for (const run of Object.values(state.runs)) {
for (const agent of Object.values(run.agents)) {
if (agent.status === "queued" || agent.status === "running") {
pendingAgents++;
}
}
}
console.log(` Pending agents: ${pendingAgents}`);
console.log(` Logs: ${state.logDir}\n`);
// Handle SIGINT gracefully
process.on("SIGINT", () => {
console.log("\n\nReceived SIGINT, shutting down...");
orchestrator?.stop();
process.exit(0);
});
try {
orchestrator = await Orchestrator.resume(state, {
maxConcurrent,
defaultDepth: depth,
permissionMode,
model,
allowedTools,
disallowedTools,
compactReminder,
debug: debugClorchestra,
debugSdk,
onAgentComplete: (fyi) => {
const status = fyi.status === "completed" ? "✅" : "❌";
console.log(`\n${status} [FYI] Agent "${fyi.agentName}" ${fyi.status}`);
},
onRunComplete: (runId) => {
console.log(`\n📊 Run ${runId} complete`);
},
});
// If additional prompt provided, use it as continuation
const continuation = prompt || "Continue from where you left off.";
await orchestrator.run(continuation);
} catch (err) {
console.error("\nError during resume:", err);
process.exit(1);
}
return;
}
// Prompt is optional - interactive mode will wait for user input
// Handle SIGINT gracefully
process.on("SIGINT", () => {
console.log("\n\nReceived SIGINT, shutting down...");
orchestrator?.stop();
process.exit(0);
});
// Load settings
const settings = getSettings();
// Apply global defaults from settings if not specified on command line
const globalSettings = settings.getGlobal();
if (!model && globalSettings.defaultModel) {
model = globalSettings.defaultModel;
}
if (!maxConcurrent && globalSettings.defaultConcurrent) {
maxConcurrent = globalSettings.defaultConcurrent;
}
if (!depth && globalSettings.defaultDepth !== undefined) {
depth = globalSettings.defaultDepth;
}
if (!permissionMode && globalSettings.defaultPermissionMode) {
permissionMode = globalSettings.defaultPermissionMode as PermissionMode;
}
try {
orchestrator = new Orchestrator({
sessionId,
maxConcurrent,
defaultDepth: depth,
permissionMode,
model,
allowedTools,
disallowedTools,
compactReminder,
debug: debugClorchestra,
debugSdk,
onAgentComplete: (fyi) => {
const status = fyi.status === "completed" ? "✅" : "❌";
console.log(`\n${status} [FYI] Agent "${fyi.agentName}" ${fyi.status}`);
},
onRunComplete: (runId) => {
console.log(`\n📊 Run ${runId} complete`);
},
});
// Add to history if prompt provided
if (prompt) {
settings.addHistory(prompt, process.cwd(), sessionId);
saveSettings();
}
await orchestrator.run(prompt);
} catch (error) {
console.error("Error:", error instanceof Error ? error.message : error);
process.exit(1);
} finally {
// Ensure settings are saved
saveSettings();
}
}
function printHelp() {
console.log(`
🎭 Clo - Claude Agent Orchestrator
USAGE:
clo [options] [--] [prompt]
clo --resume <session> [continuation prompt]
OPTIONS:
--session <id> Use a specific session ID (default: auto-generated)
--resume <session> Resume a previous session (ID, path, or "latest")
--concurrent <n> Max concurrent agents (default: 10)
--depth <n> Agent depth: > 0 allows spawning subagents (default: 1)
CLAUDE CLI PASSTHROUGH:
--permission-mode <mode> Permission mode: default, acceptEdits, bypassPermissions, plan, dontAsk
--dangerously-skip-permissions Alias for --permission-mode bypassPermissions
--model <model> Model: opus, sonnet, haiku, or full model ID
--allowedTools <tools> Comma-separated list of allowed tools
--disallowedTools <tools> Comma-separated list of disallowed tools
DEBUG:
--debug Pass --debug to Claude SDK (verbose SDK output)
--debug-clo Show all SDK messages in clo (our verbose output)
OTHER:
--no-compact-reminder Disable prompt hierarchy reminder after compaction
--help, -h Show this help message
MCP TOOLS AVAILABLE:
SpawnAgents Spawn multiple agents in parallel (map-reduce)
ListAgents List status of agents (supports queuedOnly filter)
WaitForRun Wait for all agents to complete and get results
CancelSubagents Cancel agents by ID
SetSubagentPriority Change priority of queued agents
FEATURES:
- Priority queue (lower number = runs first, default: 10)
- Retries with exponential backoff + jitter (default: 3 retries)
- Per-agent timeout (default: 5 minutes)
- Auto-generated IDs if missing
- Prompt hierarchy reminder after context compaction (disable with --no-compact-reminder)
EXAMPLES:
# Interactive mode (no prompt - waits for user input)
clo
# Simple query
clo "List files in current directory"
# Map-reduce pattern
clo "Use SpawnAgents to analyze each TypeScript file in parallel"
# With specific model and permissions
clo --model opus --permission-mode acceptEdits "Refactor the codebase"
# Skip all permissions (use with caution!)
clo --dangerously-skip-permissions "Run all tests"
# Use -- to separate flags from prompt
clo -- analyze the --help flag usage
LOGS:
Logs are stored in ~/.clo/runs/<session-id>/
Each agent has its own JSONL log file with OpenTelemetry-compatible events.
`);
}
main();
/**
* Interactive mode handler using Ink (React for CLI)
*
* Features:
* - Multi-view chat: main loop + subagent chats
* - Down arrow to show agent list
* - Ctrl+C to clear input / exit subagent / quit
* - Real-time stats footer
*/
import React, { useState, useCallback, useEffect } from "react";
import { render, Box, Text, useApp, useInput, useStdout } from "ink";
import SelectInput from "ink-select-input";
import Spinner from "ink-spinner";
import TextInput from "ink-text-input";
// =============================================================================
// Types
// =============================================================================
export interface Question {
id: string;
source: string;
text: string;
options?: Array<{ label: string; description?: string }>;
multiSelect?: boolean;
}
export interface FooterStats {
agents: { active: number; queued: number; completed: number; failed: number };
tokens: { input: number; output: number; cacheRead: number; cacheWrite: number };
elapsedMs: number;
costUsd: number;
}
export interface ChatMessage {
role: "user" | "assistant" | "system";
content: string;
timestamp: string;
}
export interface AgentInfo {
id: string;
name: string;
status: "queued" | "running" | "completed" | "failed" | "cancelled";
parentAgentId?: string;
depth: number;
}
/** Tree node for hierarchical agent display */
interface AgentTreeNode {
agent: AgentInfo;
children: AgentTreeNode[];
}
export interface InteractiveHandler {
// Permission prompts (existing)
askQuestion(question: Question): Promise<string>;
showMessage(source: string, message: string): void;
showProgress(source: string, message: string): void;
hideProgress(): void;
updateStats(stats: FooterStats): void;
// Chat functionality (new)
onMainMessage(message: ChatMessage): void;
onAgentMessage(agentId: string, message: ChatMessage): void;
updateAgentList(agents: AgentInfo[]): void;
onUserInput(callback: (target: "main" | string, content: string) => void): void;
close(): void;
}
type View = "main" | "agent-list" | "agent";
interface AppState {
view: View;
currentAgentId: string | null;
inputValue: string;
mainMessages: ChatMessage[];
agentMessages: Map<string, ChatMessage[]>;
agents: AgentInfo[];
currentQuestion: Question | null;
progressSource: string | null;
progressMessage: string | null;
footerStats: FooterStats | null;
}
// =============================================================================
// Utility Functions
// =============================================================================
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `${hours}h${minutes % 60}m`;
if (minutes > 0) return `${minutes}m${seconds % 60}s`;
return `${seconds}s`;
}
function formatTokens(n: number): string {
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
function truncateMessage(content: string, maxLines: number = 3): string {
const lines = content.split("\n");
if (lines.length <= maxLines) return content;
return lines.slice(0, maxLines).join("\n") + `\n... (${lines.length - maxLines} more lines)`;
}
/**
* Build a tree structure from flat agent list
*/
function buildAgentTree(agents: AgentInfo[]): AgentTreeNode[] {
const nodeMap = new Map<string, AgentTreeNode>();
const roots: AgentTreeNode[] = [];
// Create nodes for all agents
for (const agent of agents) {
nodeMap.set(agent.id, { agent, children: [] });
}
// Build tree structure
for (const agent of agents) {
const node = nodeMap.get(agent.id)!;
if (agent.parentAgentId && nodeMap.has(agent.parentAgentId)) {
// Add as child of parent
nodeMap.get(agent.parentAgentId)!.children.push(node);
} else {
// Root node (no parent or parent not found)
roots.push(node);
}
}
return roots;
}
/**
* Flatten tree to list with indentation info for display
*/
interface FlatTreeItem {
agent: AgentInfo;
indent: number;
isLast: boolean;
parentIsLast: boolean[];
}
function flattenTree(nodes: AgentTreeNode[], indent: number = 0, parentIsLast: boolean[] = []): FlatTreeItem[] {
const result: FlatTreeItem[] = [];
nodes.forEach((node, index) => {
const isLast = index === nodes.length - 1;
result.push({
agent: node.agent,
indent,
isLast,
parentIsLast: [...parentIsLast],
});
if (node.children.length > 0) {
result.push(...flattenTree(node.children, indent + 1, [...parentIsLast, isLast]));
}
});
return result;
}
// =============================================================================
// Components
// =============================================================================
interface HeaderProps {
view: View;
agentName?: string;
agentStatus?: string;
}
function Header({ view, agentName, agentStatus }: HeaderProps) {
if (view === "main") {
return (
<Box marginBottom={1}>
<Text color="cyan" bold>🎭 Main Orchestrator</Text>
<Text color="gray"> | ↓ agents | Ctrl+C: quit</Text>
</Box>
);
}
if (view === "agent-list") {
return (
<Box marginBottom={1}>
<Text color="yellow" bold>📋 Select Agent</Text>
<Text color="gray"> | ↑↓ navigate | Enter: select | Esc: back</Text>
</Box>
);
}
// Agent view
const statusColors = {
running: "green",
queued: "yellow",
completed: "cyan",
failed: "red",
cancelled: "gray",
} as const;
return (
<Box marginBottom={1}>
<Text color="magenta" bold>🤖 {agentName}</Text>
<Text> </Text>
<Text color={statusColors[agentStatus as keyof typeof statusColors] ?? "gray"}>
[{agentStatus}]
</Text>
<Text color="gray"> | Ctrl+C: back to main</Text>
</Box>
);
}
interface MessageListProps {
messages: ChatMessage[];
maxVisible?: number;
}
function MessageList({ messages, maxVisible = 10 }: MessageListProps) {
const visibleMessages = messages.slice(-maxVisible);
return (
<Box flexDirection="column" marginBottom={1}>
{visibleMessages.length === 0 ? (
<Text color="gray" dimColor>No messages yet...</Text>
) : (
visibleMessages.map((msg, i) => (
<Box key={i} marginBottom={msg.role === "assistant" ? 1 : 0}>
{msg.role === "user" ? (
<Text>
<Text color="green" bold>You: </Text>
<Text>{truncateMessage(msg.content, 2)}</Text>
</Text>
) : msg.role === "assistant" ? (
<Text>
<Text color="blue" bold>Claude: </Text>
<Text>{truncateMessage(msg.content, 5)}</Text>
</Text>
) : (
<Text color="gray" dimColor>[{msg.content}]</Text>
)}
</Box>
))
)}
</Box>
);
}
interface InputFieldProps {
value: string;
onChange: (value: string) => void;
onSubmit: (value: string) => void;
placeholder?: string;
}
function InputField({ value, onChange, onSubmit, placeholder }: InputFieldProps) {
return (
<Box>
<Text color="green" bold>&gt; </Text>
<TextInput
value={value}
onChange={onChange}
onSubmit={onSubmit}
placeholder={placeholder ?? "Type a message..."}
/>
</Box>
);
}
interface AgentListProps {
agents: AgentInfo[];
onSelect: (agentId: string) => void;
onCancel: () => void;
}
const statusIcons: Record<string, string> = {
running: "🔄",
queued: "⏳",
completed: "✅",
failed: "❌",
cancelled: "⛔",
};
/**
* Generate tree prefix characters (├── └── │ etc)
*/
function getTreePrefix(item: FlatTreeItem): string {
if (item.indent === 0) return "";
let prefix = "";
// Add vertical lines for parent levels
for (let i = 0; i < item.indent - 1; i++) {
prefix += item.parentIsLast[i] ? " " : "│ ";
}
// Add branch for this level
prefix += item.isLast ? "└── " : "├── ";
return prefix;
}
/**
* Filter agents to navigable ones: pending/active or top-level
*/
function filterNavigableAgents(agents: AgentInfo[]): AgentInfo[] {
// Get all agent IDs that are pending/active
const activeIds = new Set(
agents
.filter(a => a.status === "queued" || a.status === "running")
.map(a => a.id)
);
// Also include ancestors of active agents to maintain tree structure
const agentMap = new Map(agents.map(a => [a.id, a]));
const includedIds = new Set<string>();
// Add active agents and all their ancestors
for (const id of activeIds) {
let current: AgentInfo | undefined = agentMap.get(id);
while (current) {
includedIds.add(current.id);
current = current.parentAgentId ? agentMap.get(current.parentAgentId) : undefined;
}
}
// Add top-level agents (no parent)
for (const agent of agents) {
if (!agent.parentAgentId) {
includedIds.add(agent.id);
}
}
return agents.filter(a => includedIds.has(a.id));
}
function AgentList({ agents, onSelect, onCancel }: AgentListProps) {
// Filter to navigable agents: pending/active or top-level
const navigableAgents = filterNavigableAgents(agents);
// Build tree structure
const tree = buildAgentTree(navigableAgents);
const flatItems = flattenTree(tree);
const items = flatItems.map(item => ({
label: `${getTreePrefix(item)}${statusIcons[item.agent.status]} ${item.agent.name} [${item.agent.status}]`,
value: item.agent.id,
}));
if (items.length === 0) {
return (
<Box flexDirection="column">
<Text color="gray">No agents spawned yet.</Text>
<Text color="gray" dimColor>Press Esc to go back.</Text>
</Box>
);
}
// Handle Esc key
useInput((input, key) => {
if (key.escape) {
onCancel();
}
});
return (
<Box flexDirection="column">
<SelectInput
items={items}
onSelect={(item) => onSelect(item.value)}
/>
</Box>
);
}
interface SelectPromptProps {
question: Question;
onSelect: (value: string) => void;
}
function SelectPrompt({ question, onSelect }: SelectPromptProps) {
const items = (question.options || []).map((opt, i) => ({
label: opt.label,
value: opt.label,
key: String(i),
}));
items.push({ label: "Other (type custom response)", value: "__other__", key: "other" });
const [showCustomInput, setShowCustomInput] = useState(false);
const [customValue, setCustomValue] = useState("");
const handleSelect = useCallback((item: { value: string }) => {
if (item.value === "__other__") {
setShowCustomInput(true);
} else {
onSelect(item.value);
}
}, [onSelect]);
const handleCustomSubmit = useCallback((value: string) => {
onSelect(value || items[0].value);
}, [onSelect, items]);
useInput((input, key) => {
if (showCustomInput && key.escape) {
setShowCustomInput(false);
setCustomValue("");
}
});
return (
<Box flexDirection="column" marginY={1}>
<Box marginBottom={1}>
<Text color="cyan" bold>┌─ </Text>
<Text color="yellow">{question.source}</Text>
<Text color="cyan" bold> asks:</Text>
</Box>
<Box marginLeft={2} marginBottom={1}>
<Text>{question.text}</Text>
</Box>
{!showCustomInput ? (
<Box marginLeft={2}>
<SelectInput items={items} onSelect={handleSelect} />
</Box>
) : (
<Box marginLeft={2} flexDirection="column">
<Text color="gray">Type your response (Enter to submit, Esc to cancel):</Text>
<Box>
<Text color="green">&gt; </Text>
<TextInput
value={customValue}
onChange={setCustomValue}
onSubmit={handleCustomSubmit}
/>
</Box>
</Box>
)}
</Box>
);
}
interface FooterProps {
stats: FooterStats;
agents: AgentInfo[];
view: View;
currentAgentId: string | null;
}
/**
* Build breadcrumb trail: main > parent > grandparent > ... > current
*/
function buildBreadcrumb(agents: AgentInfo[], currentAgentId: string | null): string[] {
if (!currentAgentId) return [];
const agentMap = new Map(agents.map(a => [a.id, a]));
const trail: string[] = [];
let current = agentMap.get(currentAgentId);
while (current) {
trail.unshift(current.name);
current = current.parentAgentId ? agentMap.get(current.parentAgentId) : undefined;
}
return trail;
}
/**
* Count navigable agents (pending/active or top-level)
*/
function countNavigableAgents(agents: AgentInfo[]): number {
return agents.filter(a =>
a.status === "queued" || a.status === "running" || !a.parentAgentId
).length;
}
function Footer({ stats, agents, view, currentAgentId }: FooterProps) {
const { agents: agentStats, tokens, elapsedMs, costUsd } = stats;
const totalTokens = tokens.input + tokens.output;
const breadcrumb = buildBreadcrumb(agents, currentAgentId);
const navigableCount = countNavigableAgents(agents);
return (
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1} flexDirection="column">
{/* Breadcrumb for sub-agent view */}
{view === "agent" && breadcrumb.length > 0 && (
<Box>
<Text color="gray">main</Text>
{breadcrumb.map((name, i) => (
<Text key={i}>
<Text color="gray"></Text>
<Text color={i === breadcrumb.length - 1 ? "cyan" : "gray"}>{name}</Text>
</Text>
))}
</Box>
)}
<Box>
{(agentStats.active > 0 || agentStats.queued > 0) && (
<>
<Text color="cyan">
<Spinner type="dots" />
</Text>
<Text> </Text>
</>
)}
{elapsedMs > 0 && (
<>
<Text color="white" bold>{formatDuration(elapsedMs)}</Text>
<Text color="gray"> · </Text>
</>
)}
{agentStats.active > 0 && (
<>
<Text color="green" bold>{agentStats.active}</Text>
<Text color="gray"> active</Text>
</>
)}
{agentStats.queued > 0 && (
<>
{agentStats.active > 0 && <Text color="gray"> · </Text>}
<Text color="yellow">{agentStats.queued}</Text>
<Text color="gray"> queued</Text>
</>
)}
{agentStats.completed > 0 && (
<>
{(agentStats.active > 0 || agentStats.queued > 0) && <Text color="gray"> · </Text>}
<Text color="green">{agentStats.completed}</Text>
<Text color="gray"> done</Text>
</>
)}
{agentStats.failed > 0 && (
<>
{(agentStats.active > 0 || agentStats.queued > 0 || agentStats.completed > 0) && <Text color="gray"> · </Text>}
<Text color="red">{agentStats.failed}</Text>
<Text color="gray"> failed</Text>
</>
)}
{totalTokens > 0 && (
<>
<Text color="gray"></Text>
<Text color="cyan">{formatTokens(totalTokens)}</Text>
<Text color="gray"> tok</Text>
</>
)}
{costUsd > 0 && (
<>
<Text color="gray"> · </Text>
<Text color="yellow">${costUsd.toFixed(4)}</Text>
</>
)}
</Box>
{(view === "main" || view === "agent") && navigableCount > 0 && (
<Box>
<Text color="gray" dimColor>Press ↓ to navigate agents</Text>
</Box>
)}
</Box>
);
}
// =============================================================================
// Main App Component
// =============================================================================
interface InkController {
updateState(updates: Partial<AppState>): void;
getState(): AppState;
exit(): void;
}
interface AppProps {
initialState: AppState;
onQuestionAnswer: (answer: string) => void;
onUserInput: (target: "main" | string, content: string) => void;
controllerRef: { current: InkController | null };
}
function App({ initialState, onQuestionAnswer, onUserInput, controllerRef }: AppProps) {
const [state, setState] = useState<AppState>(initialState);
const { exit } = useApp();
// Expose controller
useEffect(() => {
controllerRef.current = {
updateState: (updates: Partial<AppState>) => {
setState(prev => ({ ...prev, ...updates }));
},
getState: () => state,
exit,
};
return () => {
controllerRef.current = null;
};
}, [exit, controllerRef, state]);
// Check if there are navigable agents
const hasNavigableAgents = countNavigableAgents(state.agents) > 0;
// Handle keyboard input
useInput((input, key) => {
// Down arrow in main or agent view -> show agent list (if navigable agents exist)
if ((state.view === "main" || state.view === "agent") && key.downArrow && hasNavigableAgents && !state.currentQuestion) {
setState(prev => ({ ...prev, view: "agent-list" }));
return;
}
// Ctrl+C handling
if (input === "\x03") { // Ctrl+C
if (state.inputValue.length > 0) {
// Clear input
setState(prev => ({ ...prev, inputValue: "" }));
} else if (state.view === "agent") {
// Exit agent view
setState(prev => ({ ...prev, view: "main", currentAgentId: null }));
} else if (state.view === "agent-list") {
// Exit agent list
setState(prev => ({ ...prev, view: "main" }));
} else {
// Quit from main view
exit();
}
return;
}
// Escape in agent list -> back to main
if (state.view === "agent-list" && key.escape) {
setState(prev => ({ ...prev, view: "main" }));
}
});
const handleInputChange = useCallback((value: string) => {
setState(prev => ({ ...prev, inputValue: value }));
}, []);
const handleInputSubmit = useCallback((value: string) => {
if (!value.trim()) return;
const target = state.view === "agent" && state.currentAgentId
? state.currentAgentId
: "main";
// Add user message to local state
const userMessage: ChatMessage = {
role: "user",
content: value,
timestamp: new Date().toISOString(),
};
if (target === "main") {
setState(prev => ({
...prev,
inputValue: "",
mainMessages: [...prev.mainMessages, userMessage],
}));
} else {
setState(prev => {
const newAgentMessages = new Map(prev.agentMessages);
const existing = newAgentMessages.get(target) || [];
newAgentMessages.set(target, [...existing, userMessage]);
return {
...prev,
inputValue: "",
agentMessages: newAgentMessages,
};
});
}
// Send to handler
onUserInput(target, value);
}, [state.view, state.currentAgentId, onUserInput]);
const handleAgentSelect = useCallback((agentId: string) => {
setState(prev => ({
...prev,
view: "agent",
currentAgentId: agentId,
}));
}, []);
const handleAgentListCancel = useCallback(() => {
setState(prev => ({ ...prev, view: "main" }));
}, []);
const handleQuestionSelect = useCallback((value: string) => {
setState(prev => ({ ...prev, currentQuestion: null }));
onQuestionAnswer(value);
}, [onQuestionAnswer]);
// Get current agent info
const currentAgent = state.currentAgentId
? state.agents.find(a => a.id === state.currentAgentId)
: null;
// Get messages for current view
const currentMessages = state.view === "agent" && state.currentAgentId
? (state.agentMessages.get(state.currentAgentId) || [])
: state.mainMessages;
return (
<Box flexDirection="column" padding={1}>
<Header
view={state.view}
agentName={currentAgent?.name}
agentStatus={currentAgent?.status}
/>
{/* Agent list view */}
{state.view === "agent-list" && (
<AgentList
agents={state.agents}
onSelect={handleAgentSelect}
onCancel={handleAgentListCancel}
/>
)}
{/* Main or agent chat view */}
{(state.view === "main" || state.view === "agent") && (
<>
<MessageList messages={currentMessages} />
{/* Progress indicator */}
{state.progressSource && state.progressMessage && (
<Box marginBottom={1}>
<Text color="cyan">
<Spinner type="dots" />
</Text>
<Text color="yellow"> [{state.progressSource}] </Text>
<Text>{state.progressMessage}</Text>
</Box>
)}
{/* Permission question */}
{state.currentQuestion && (
<SelectPrompt
question={state.currentQuestion}
onSelect={handleQuestionSelect}
/>
)}
{/* Input field (only when not answering a question) */}
{!state.currentQuestion && (
<InputField
value={state.inputValue}
onChange={handleInputChange}
onSubmit={handleInputSubmit}
placeholder={state.view === "agent" ? `Message to ${currentAgent?.name}...` : "Message to orchestrator..."}
/>
)}
</>
)}
{/* Footer */}
{state.footerStats && (
<Footer
stats={state.footerStats}
agents={state.agents}
view={state.view}
currentAgentId={state.currentAgentId}
/>
)}
</Box>
);
}
// =============================================================================
// Ink Interactive Handler
// =============================================================================
export function createInkInteractiveHandler(): InteractiveHandler {
let questionResolver: ((answer: string) => void) | null = null;
let userInputCallback: ((target: "main" | string, content: string) => void) | null = null;
let inkInstance: ReturnType<typeof render> | null = null;
const controllerRef: { current: InkController | null } = { current: null };
let currentState: AppState = {
view: "main",
currentAgentId: null,
inputValue: "",
mainMessages: [],
agentMessages: new Map(),
agents: [],
currentQuestion: null,
progressSource: null,
progressMessage: null,
footerStats: null,
};
const updateState = (updates: Partial<AppState>) => {
currentState = { ...currentState, ...updates };
if (controllerRef.current) {
controllerRef.current.updateState(updates);
}
};
const ensureRendered = () => {
if (!inkInstance) {
inkInstance = render(
<App
initialState={currentState}
onQuestionAnswer={(answer) => {
if (questionResolver) {
questionResolver(answer);
questionResolver = null;
}
}}
onUserInput={(target, content) => {
if (userInputCallback) {
userInputCallback(target, content);
}
}}
controllerRef={controllerRef}
/>
);
}
};
return {
async askQuestion(question: Question): Promise<string> {
ensureRendered();
return new Promise((resolve) => {
questionResolver = resolve;
updateState({ currentQuestion: question });
});
},
showMessage(source: string, message: string): void {
ensureRendered();
// Add as system message to main
const chatMsg: ChatMessage = {
role: "system",
content: `[${source}] ${message}`,
timestamp: new Date().toISOString(),
};
updateState({
mainMessages: [...currentState.mainMessages.slice(-50), chatMsg],
});
},
showProgress(source: string, message: string): void {
ensureRendered();
updateState({ progressSource: source, progressMessage: message });
},
hideProgress(): void {
updateState({ progressSource: null, progressMessage: null });
},
updateStats(stats: FooterStats): void {
ensureRendered();
updateState({ footerStats: stats });
},
onMainMessage(message: ChatMessage): void {
ensureRendered();
updateState({
mainMessages: [...currentState.mainMessages.slice(-50), message],
});
},
onAgentMessage(agentId: string, message: ChatMessage): void {
ensureRendered();
const newAgentMessages = new Map(currentState.agentMessages);
const existing = newAgentMessages.get(agentId) || [];
newAgentMessages.set(agentId, [...existing.slice(-50), message]);
updateState({ agentMessages: newAgentMessages });
},
updateAgentList(agents: AgentInfo[]): void {
ensureRendered();
updateState({ agents });
},
onUserInput(callback: (target: "main" | string, content: string) => void): void {
userInputCallback = callback;
},
close(): void {
if (controllerRef.current) {
controllerRef.current.exit();
}
if (inkInstance) {
inkInstance.unmount();
inkInstance = null;
}
},
};
}
// =============================================================================
// Simple readline fallback (for non-TTY environments)
// =============================================================================
import * as readline from "node:readline";
export function createReadlineInteractiveHandler(): InteractiveHandler {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
let userInputCallback: ((target: "main" | string, content: string) => void) | null = null;
return {
async askQuestion(question: Question): Promise<string> {
return new Promise((resolve) => {
console.log();
console.log(`┌─ ${question.source} asks:`);
console.log(`│ ${question.text}`);
if (question.options && question.options.length > 0) {
console.log("│");
question.options.forEach((opt, i) => {
const desc = opt.description ? ` - ${opt.description}` : "";
console.log(`│ [${i + 1}] ${opt.label}${desc}`);
});
console.log("│");
console.log(`│ Enter a number (1-${question.options.length}) or type a custom response:`);
}
console.log("└─");
rl.question(" > ", (answer) => {
if (question.options && /^\d+$/.test(answer.trim())) {
const idx = parseInt(answer.trim(), 10) - 1;
if (idx >= 0 && idx < question.options.length) {
resolve(question.options[idx].label);
return;
}
}
resolve(answer);
});
});
},
showMessage(source: string, message: string): void {
const prefix = source === "main" ? "📣" : `🤖 [${source}]`;
console.log(`${prefix} ${message}`);
},
showProgress(source: string, message: string): void {
const prefix = source === "main" ? "⏳" : `⏳ [${source}]`;
process.stdout.write(`\r${prefix} ${message}`);
},
hideProgress(): void {
process.stdout.write("\r\x1b[K");
},
updateStats(stats: FooterStats): void {
const { agents, tokens, elapsedMs, costUsd } = stats;
const totalTokens = tokens.input + tokens.output;
const elapsed = formatDuration(elapsedMs);
let line = `\r\x1b[K⏳ ${elapsed} · ${agents.active} active`;
if (agents.queued > 0) line += ` · ${agents.queued} queued`;
if (agents.completed > 0) line += ` · ${agents.completed} done`;
if (agents.failed > 0) line += ` · ${agents.failed} failed`;
if (totalTokens > 0) {
line += ` │ ${formatTokens(totalTokens)} tok`;
if (tokens.cacheRead > 0) line += ` (${formatTokens(tokens.cacheRead)} cached)`;
}
if (costUsd > 0) line += ` · $${costUsd.toFixed(4)}`;
process.stdout.write(line);
},
onMainMessage(message: ChatMessage): void {
const prefix = message.role === "user" ? "You" : "Claude";
console.log(`\n${prefix}: ${message.content}`);
},
onAgentMessage(agentId: string, message: ChatMessage): void {
const prefix = message.role === "user" ? "You" : agentId;
console.log(`\n[${agentId}] ${prefix}: ${message.content}`);
},
updateAgentList(_agents: AgentInfo[]): void {
// No-op in readline mode
},
onUserInput(callback: (target: "main" | string, content: string) => void): void {
userInputCallback = callback;
// Start reading user input
const promptInput = () => {
rl.question("> ", (answer) => {
if (answer.trim() && userInputCallback) {
userInputCallback("main", answer);
}
promptInput();
});
};
promptInput();
},
close(): void {
rl.close();
},
};
}
// =============================================================================
// Non-interactive handler (for batch/CI mode)
// =============================================================================
export function createNonInteractiveHandler(): InteractiveHandler {
return {
async askQuestion(question: Question): Promise<string> {
if (question.options && question.options.length > 0) {
console.log(`[AUTO] Question from ${question.source}: "${question.text}" → ${question.options[0].label}`);
return question.options[0].label;
}
console.log(`[SKIP] Question from ${question.source}: "${question.text}" (no default available)`);
return "";
},
showMessage(source: string, message: string): void {
console.log(`[${source}] ${message}`);
},
showProgress(_source: string, _message: string): void {},
hideProgress(): void {},
updateStats(_stats: FooterStats): void {},
onMainMessage(_message: ChatMessage): void {},
onAgentMessage(_agentId: string, _message: ChatMessage): void {},
updateAgentList(_agents: AgentInfo[]): void {},
onUserInput(_callback: (target: "main" | string, content: string) => void): void {},
close(): void {},
};
}
// =============================================================================
// Factory
// =============================================================================
export function createInteractiveHandler(): InteractiveHandler {
if (process.stdin.isTTY && process.stdout.isTTY) {
try {
return createInkInteractiveHandler();
} catch {
return createReadlineInteractiveHandler();
}
}
return createReadlineInteractiveHandler();
}
/**
* JSONL logging utilities (OpenTelemetry GenAI conventions)
*/
import { createWriteStream, WriteStream, existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import type { LogLevel, LogEventType, BaseLogEvent } from "./types.js";
import { generateTraceId, generateSpanId, nowISO, truncate } from "./types.js";
// =============================================================================
// Logger
// =============================================================================
export interface LoggerOptions {
filePath: string;
service: string;
traceId?: string;
minLevel?: LogLevel;
console?: boolean;
}
const LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 0, info: 1, warn: 2, error: 3,
};
export class JSONLLogger {
private stream: WriteStream | null = null;
private filePath: string;
private service: string;
private traceId: string;
private minLevel: LogLevel;
private toConsole: boolean;
constructor(opts: LoggerOptions) {
this.filePath = opts.filePath;
this.service = opts.service;
this.traceId = opts.traceId ?? generateTraceId();
this.minLevel = opts.minLevel ?? "info";
this.toConsole = opts.console ?? false;
const dir = dirname(this.filePath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
this.stream = createWriteStream(this.filePath, { flags: "a" });
}
write(event: Record<string, unknown>): void {
const level = (event.level as LogLevel) ?? "info";
if (LEVEL_PRIORITY[level] < LEVEL_PRIORITY[this.minLevel]) return;
const line = JSON.stringify(event) + "\n";
if (this.toConsole) {
const colors: Record<LogLevel, string> = {
debug: "\x1b[90m", info: "\x1b[36m", warn: "\x1b[33m", error: "\x1b[31m",
};
console.log(`${colors[level]}[${level.toUpperCase()}]\x1b[0m`, event.event_type);
}
this.stream?.write(line);
}
child(opts: { spanId?: string; agentId?: string; agentName?: string }): LoggerContext {
return new LoggerContext(this, {
traceId: this.traceId,
spanId: opts.spanId ?? generateSpanId(),
agentId: opts.agentId,
agentName: opts.agentName,
service: this.service,
});
}
async close(): Promise<void> {
return new Promise(resolve => {
this.stream ? this.stream.end(() => resolve()) : resolve();
});
}
get path(): string { return this.filePath; }
}
// =============================================================================
// Logger Context
// =============================================================================
interface ContextOpts {
traceId: string;
spanId: string;
parentSpanId?: string;
agentId?: string;
agentName?: string;
service: string;
}
export class LoggerContext {
constructor(private logger: JSONLLogger, private opts: ContextOpts) {}
private base(eventType: LogEventType, level: LogLevel): BaseLogEvent {
return {
timestamp: nowISO(),
event_type: eventType,
level,
trace_id: this.opts.traceId,
span_id: this.opts.spanId,
parent_span_id: this.opts.parentSpanId,
service: this.opts.service,
"gen_ai.agent.id": this.opts.agentId,
"gen_ai.agent.name": this.opts.agentName,
};
}
child(opts?: { spanId?: string }): LoggerContext {
return new LoggerContext(this.logger, {
...this.opts,
parentSpanId: this.opts.spanId,
spanId: opts?.spanId ?? generateSpanId(),
});
}
agentCreated(data: { itemId: string; provider: string; model: string; depth: number; context?: string }): void {
this.logger.write({
...this.base("agent.created", "info"),
"gen_ai.provider.name": data.provider,
"gen_ai.request.model": data.model,
item_id: data.itemId,
depth: data.depth,
context_preview: data.context ? truncate(data.context, 500) : undefined,
});
}
agentInvoked(data: { prompt?: string }): void {
this.logger.write({
...this.base("agent.invoked", "info"),
prompt_preview: data.prompt ? truncate(data.prompt, 500) : undefined,
});
}
agentCompleted(data: {
status: "success" | "failure";
durationMs: number;
inputTokens?: number;
outputTokens?: number;
costUsd?: number;
numTurns?: number;
result?: string;
error?: string;
}): void {
this.logger.write({
...this.base("agent.completed", data.status === "success" ? "info" : "error"),
status: data.status,
duration_ms: data.durationMs,
"gen_ai.usage.input_tokens": data.inputTokens,
"gen_ai.usage.output_tokens": data.outputTokens,
cost_usd: data.costUsd,
num_turns: data.numTurns,
result_preview: data.result ? truncate(data.result, 500) : undefined,
error_message: data.error,
});
}
toolCalled(data: { toolName: string; callId: string; arguments?: unknown }): void {
this.logger.write({
...this.base("tool.called", "info"),
"gen_ai.tool.name": data.toolName,
"gen_ai.tool.call.id": data.callId,
"gen_ai.tool.call.arguments": data.arguments,
});
}
toolResult(data: { toolName: string; callId: string; durationMs: number; status: "success" | "error"; error?: string }): void {
this.logger.write({
...this.base("tool.result", data.status === "success" ? "info" : "error"),
"gen_ai.tool.name": data.toolName,
"gen_ai.tool.call.id": data.callId,
duration_ms: data.durationMs,
status: data.status,
error_message: data.error,
});
}
messageReceived(data: { messageType: string; subtype?: string }): void {
this.logger.write({
...this.base("message.received", "debug"),
message_type: data.messageType,
message_subtype: data.subtype,
});
}
fyiInjected(data: { sourceAgentId: string; sourceAgentName: string; status: "completed" | "failed" }): void {
this.logger.write({
...this.base("fyi.injected", "info"),
source_agent_id: data.sourceAgentId,
source_agent_name: data.sourceAgentName,
status: data.status,
});
}
error(message: string, err?: Error): void {
this.logger.write({
...this.base("agent.error", "error"),
error_message: message,
error_stack: err?.stack,
});
}
info(message: string, data?: Record<string, unknown>): void {
this.logger.write({
...this.base("message.received", "info"),
message,
...data,
});
}
warn(message: string, data?: Record<string, unknown>): void {
this.logger.write({
...this.base("message.received", "warn"),
message,
...data,
});
}
debug(message: string, data?: Record<string, unknown>): void {
this.logger.write({
...this.base("message.received", "debug"),
message,
...data,
});
}
}
// =============================================================================
// Deferred Logger Context
// =============================================================================
/**
* A LoggerContext that buffers calls until initialized with a real context.
* Used when we need to start logging before we have the session ID.
*/
interface BufferedCall {
method: string;
args: unknown[];
}
export class DeferredLoggerContext {
private buffer: BufferedCall[] = [];
private realContext: LoggerContext | null = null;
/** Initialize with the real context - replays all buffered calls */
initialize(context: LoggerContext): void {
if (this.realContext) return; // Already initialized
this.realContext = context;
for (const { method, args } of this.buffer) {
(context as unknown as Record<string, (...a: unknown[]) => void>)[method](...args);
}
this.buffer = [];
}
get isInitialized(): boolean {
return this.realContext !== null;
}
private call(method: string, ...args: unknown[]): void {
if (this.realContext) {
(this.realContext as unknown as Record<string, (...a: unknown[]) => void>)[method](...args);
} else {
this.buffer.push({ method, args });
}
}
// Proxy all LoggerContext methods
agentCreated(data: Parameters<LoggerContext["agentCreated"]>[0]): void {
this.call("agentCreated", data);
}
agentInvoked(data: Parameters<LoggerContext["agentInvoked"]>[0]): void {
this.call("agentInvoked", data);
}
agentCompleted(data: Parameters<LoggerContext["agentCompleted"]>[0]): void {
this.call("agentCompleted", data);
}
toolCalled(data: Parameters<LoggerContext["toolCalled"]>[0]): void {
this.call("toolCalled", data);
}
toolResult(data: Parameters<LoggerContext["toolResult"]>[0]): void {
this.call("toolResult", data);
}
messageReceived(data: Parameters<LoggerContext["messageReceived"]>[0]): void {
this.call("messageReceived", data);
}
fyiInjected(data: Parameters<LoggerContext["fyiInjected"]>[0]): void {
this.call("fyiInjected", data);
}
error(message: string, err?: Error): void {
this.call("error", message, err);
}
info(message: string, data?: Record<string, unknown>): void {
this.call("info", message, data);
}
warn(message: string, data?: Record<string, unknown>): void {
this.call("warn", message, data);
}
debug(message: string, data?: Record<string, unknown>): void {
this.call("debug", message, data);
}
}
// =============================================================================
// Factory
// =============================================================================
export function createRunLogger(sessionId: string, runId: string, logBaseDir: string, opts?: Partial<LoggerOptions>): JSONLLogger {
return new JSONLLogger({
filePath: join(logBaseDir, sessionId, runId, "main.jsonl"),
service: "clorchestra",
...opts,
});
}
export function createAgentLogger(sessionId: string, runId: string, agentId: string, logBaseDir: string, opts?: Partial<LoggerOptions>): JSONLLogger {
return new JSONLLogger({
filePath: join(logBaseDir, sessionId, runId, "agents", `${agentId}.jsonl`),
service: "clorchestra-agent",
...opts,
});
}
/**
* MCP Tools: SpawnAgents, ListAgents, CancelSubagents, SetSubagentPriority, WaitForRun
*/
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
import type { AgentManager } from "./agent-manager.js";
import type {
SpawnAgentsResult,
ListAgentsResult,
CancelSubagentsResult,
SetSubagentPriorityResult,
AgentStatus,
} from "./types.js";
// =============================================================================
// Zod Schemas
// =============================================================================
const RetryConfigSchema = z.object({
maxRetries: z.number().optional().describe("Max retry attempts (default: 3)"),
baseDelayMs: z.number().optional().describe("Base delay in ms (default: 1000)"),
maxDelayMs: z.number().optional().describe("Max delay in ms (default: 30000)"),
backoffMultiplier: z.number().optional().describe("Backoff multiplier (default: 2)"),
jitterFactor: z.number().optional().describe("Jitter factor 0-1 (default: 0.2)"),
}).optional();
const SpawnAgentsItemSchema = z.object({
id: z.string().optional().describe("Unique identifier (auto-generated if missing)"),
prompt: z.string().describe("The prompt for this specific agent"),
data: z.any().optional().describe("Optional data payload"),
priority: z.number().optional().describe("Priority (lower = runs first, default: 10)"),
timeoutMs: z.number().optional().describe("Timeout in ms (default: 300000)"),
retryConfig: RetryConfigSchema.describe("Retry configuration for this item"),
});
const SpawnAgentsOptionsSchema = z.object({
model: z.enum(["sonnet", "opus", "haiku"]).optional().describe("Model to use"),
maxTurns: z.number().optional().describe("Maximum turns per agent"),
maxBudgetUsd: z.number().optional().describe("Maximum budget per agent in USD"),
allowedTools: z.array(z.string()).optional().describe("Tools available to agents"),
disallowedTools: z.array(z.string()).optional().describe("Tools denied to agents"),
maxConcurrent: z.number().optional().describe("Max concurrent agents (default: 10)"),
depth: z.number().optional().describe("Agent depth: > 0 allows spawning subagents"),
priority: z.number().optional().describe("Default priority for all items"),
timeoutMs: z.number().optional().describe("Default timeout in ms for all items"),
retryConfig: RetryConfigSchema.describe("Default retry configuration"),
parentAgentId: z.string().optional().describe("Your agent ID (from <agent-info>), for hierarchy tracking"),
}).optional();
// =============================================================================
// Tool Implementations
// =============================================================================
export function createSpawnAgentsTool(manager: AgentManager) {
return tool(
"SpawnAgents",
"Spawn multiple agents in parallel to process items (map-reduce pattern). Returns immediately with runId; use ListAgents to monitor progress or WaitForRun to block until complete.",
{
context: z.string().describe("System prompt/context shared by all agents"),
items: z.array(SpawnAgentsItemSchema).describe("Array of items, each spawning one agent"),
options: SpawnAgentsOptionsSchema,
},
async (args): Promise<{ content: Array<{ type: "text"; text: string }> }> => {
const runState = await manager.spawnAgents({
context: args.context,
items: args.items,
options: args.options,
});
const agentIds = [...runState.agents.keys()];
const result: SpawnAgentsResult = {
runId: runState.runId,
agentCount: runState.agents.size,
logDir: runState.logDir,
agentIds,
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2),
}],
};
}
);
}
export function createListAgentsTool(manager: AgentManager) {
return tool(
"ListAgents",
"List status of all agents in a run. Use queuedOnly to see only queued agents, or status to filter by specific status.",
{
runId: z.string().describe("The run ID from SpawnAgents"),
queuedOnly: z.boolean().optional().describe("Only return queued agents"),
status: z.enum(["queued", "running", "completed", "failed", "cancelled"]).optional().describe("Filter by status"),
},
async (args): Promise<{ content: Array<{ type: "text"; text: string }> }> => {
const runState = manager.getRunStatus(args.runId);
if (!runState) {
throw new Error(`Run ${args.runId} not found`);
}
const summary = manager.getRunSummary(args.runId)!;
const agents = manager.getAgents(args.runId, {
queuedOnly: args.queuedOnly,
status: args.status as AgentStatus | undefined,
});
const result: ListAgentsResult = {
agents,
summary,
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2),
}],
};
}
);
}
export function createWaitForRunTool(manager: AgentManager) {
return tool(
"WaitForRun",
"Wait for all agents in a run to complete (blocking) and get final results including all successes and failures.",
{
runId: z.string().describe("The run ID from SpawnAgents"),
},
async (args): Promise<{ content: Array<{ type: "text"; text: string }> }> => {
const runState = await manager.waitForRun(args.runId);
if (!runState) {
throw new Error(`Run ${args.runId} not found`);
}
const summary = manager.getRunSummary(args.runId)!;
const agents = [...runState.agents.values()];
const results = agents
.filter(a => a.status === "completed" && a.result)
.map(a => ({ itemId: a.itemId, agentId: a.id, result: a.result, durationMs: a.durationMs, costUsd: a.costUsd }));
const errors = agents
.filter(a => a.status === "failed")
.map(a => ({ itemId: a.itemId, agentId: a.id, error: a.error, retryAttempt: a.retryAttempt }));
const cancelled = agents
.filter(a => a.status === "cancelled")
.map(a => ({ itemId: a.itemId, agentId: a.id }));
return {
content: [{
type: "text",
text: JSON.stringify({
runId: args.runId,
status: "completed",
summary,
results,
errors,
cancelled,
}, null, 2),
}],
};
}
);
}
export function createCancelSubagentsTool(manager: AgentManager) {
return tool(
"CancelSubagents",
"Cancel one or more agents by ID. Cancels both queued and running agents. Returns which agents were cancelled, not found, or already finished.",
{
ids: z.array(z.string()).describe("Agent IDs to cancel"),
},
async (args): Promise<{ content: Array<{ type: "text"; text: string }> }> => {
const result: CancelSubagentsResult = manager.cancelAgents(args.ids);
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2),
}],
};
}
);
}
export function createSetSubagentPriorityTool(manager: AgentManager) {
return tool(
"SetSubagentPriority",
"Change the priority of a queued agent. Lower priority number = runs sooner (0 = highest priority). Only affects queued agents.",
{
id: z.string().describe("Agent ID to update"),
priority: z.number().describe("New priority (lower = runs first, 0 = highest)"),
},
async (args): Promise<{ content: Array<{ type: "text"; text: string }> }> => {
const result: SetSubagentPriorityResult | null = manager.setAgentPriority(args.id, args.priority);
if (!result) {
throw new Error(`Agent ${args.id} not found`);
}
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2),
}],
};
}
);
}
// =============================================================================
// MCP Server Factory
// =============================================================================
export function createOrchestraMcpServer(manager: AgentManager) {
return createSdkMcpServer({
name: "clo",
version: "1.0.0",
tools: [
createSpawnAgentsTool(manager),
createListAgentsTool(manager),
createWaitForRunTool(manager),
createCancelSubagentsTool(manager),
createSetSubagentPriorityTool(manager),
],
});
}
/**
* 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);
}
{
"name": "clo",
"version": "1.0.0",
"description": "Claude Agent SDK coordinator CLI",
"type": "module",
"bin": "dist/cli.js",
"scripts": {
"prepare": "npm run build",
"start": "bun run cli.ts",
"build": "bun build cli.ts --outdir dist --target node && chmod +x dist/cli.js"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.55",
"ink": "^6.5.1",
"ink-select-input": "^6.2.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"react": "^19.2.3",
"react-devtools-core": "^6.1.5"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
},
"engines": {
"node": ">=18"
},
"keywords": [
"claude",
"agent",
"sdk",
"coordinator",
"cli"
],
"license": "MIT"
}
/**
* Settings persistence for clo
*
* Stores:
* - Command history (recent prompts)
* - Per-folder permissions (similar to Claude Code)
* - Global preferences
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
// =============================================================================
// Types
// =============================================================================
/** Permission action for a tool */
export type PermissionAction = "allow" | "deny" | "ask";
/** Tool permission pattern (e.g., "Bash(npm run *)", "Read(src/*)") */
export interface ToolPermission {
pattern: string;
action: PermissionAction;
/** When this permission was set */
createdAt: string;
}
/** Per-folder permission settings */
export interface FolderPermissions {
/** Folder path (absolute) */
path: string;
/** Tool permissions for this folder */
permissions: ToolPermission[];
/** Last accessed timestamp */
lastAccessed: string;
}
/** Command history entry */
export interface HistoryEntry {
/** The prompt text */
prompt: string;
/** Working directory when command was run */
cwd: string;
/** Timestamp */
timestamp: string;
/** Session ID if available */
sessionId?: string;
}
/** Global settings */
export interface GlobalSettings {
/** Default model */
defaultModel?: string;
/** Default max concurrent agents */
defaultConcurrent?: number;
/** Default agent depth */
defaultDepth?: number;
/** Default permission mode */
defaultPermissionMode?: string;
}
/** Root settings structure */
export interface CloSettings {
/** Version for migrations */
version: number;
/** Global preferences */
global: GlobalSettings;
/** Command history (most recent first) */
history: HistoryEntry[];
/** Per-folder permissions (keyed by normalized path) */
folders: Record<string, FolderPermissions>;
}
// =============================================================================
// Constants
// =============================================================================
const SETTINGS_DIR = join(homedir(), ".clo");
const SETTINGS_FILE = join(SETTINGS_DIR, "settings.json");
const CURRENT_VERSION = 1;
const MAX_HISTORY_ENTRIES = 100;
// =============================================================================
// Settings Manager
// =============================================================================
export class SettingsManager {
private settings: CloSettings;
private dirty = false;
constructor() {
this.settings = this.load();
}
/** Load settings from disk */
private load(): CloSettings {
try {
if (existsSync(SETTINGS_FILE)) {
const content = readFileSync(SETTINGS_FILE, "utf-8");
const parsed = JSON.parse(content) as CloSettings;
return this.migrate(parsed);
}
} catch (err) {
console.error(`Warning: Failed to load settings from ${SETTINGS_FILE}:`, err);
}
return this.defaultSettings();
}
/** Create default settings */
private defaultSettings(): CloSettings {
return {
version: CURRENT_VERSION,
global: {},
history: [],
folders: {},
};
}
/** Migrate settings to current version */
private migrate(settings: CloSettings): CloSettings {
// Future migrations go here
if (settings.version < CURRENT_VERSION) {
settings.version = CURRENT_VERSION;
this.dirty = true;
}
return settings;
}
/** Save settings to disk */
save(): void {
if (!this.dirty) return;
try {
if (!existsSync(SETTINGS_DIR)) {
mkdirSync(SETTINGS_DIR, { recursive: true });
}
writeFileSync(SETTINGS_FILE, JSON.stringify(this.settings, null, 2));
this.dirty = false;
} catch (err) {
console.error(`Warning: Failed to save settings to ${SETTINGS_FILE}:`, err);
}
}
// ===========================================================================
// History
// ===========================================================================
/** Add a command to history */
addHistory(prompt: string, cwd: string, sessionId?: string): void {
// Don't add empty prompts or duplicates of the last entry
if (!prompt.trim()) return;
if (this.settings.history.length > 0 && this.settings.history[0].prompt === prompt) {
return;
}
const entry: HistoryEntry = {
prompt,
cwd: resolve(cwd),
timestamp: new Date().toISOString(),
sessionId,
};
// Add to front, remove oldest if over limit
this.settings.history.unshift(entry);
if (this.settings.history.length > MAX_HISTORY_ENTRIES) {
this.settings.history = this.settings.history.slice(0, MAX_HISTORY_ENTRIES);
}
this.dirty = true;
}
/** Get command history */
getHistory(limit?: number): HistoryEntry[] {
const entries = this.settings.history;
return limit ? entries.slice(0, limit) : entries;
}
/** Search history by prompt text */
searchHistory(query: string, limit = 10): HistoryEntry[] {
const lower = query.toLowerCase();
return this.settings.history
.filter(e => e.prompt.toLowerCase().includes(lower))
.slice(0, limit);
}
// ===========================================================================
// Folder Permissions
// ===========================================================================
/** Normalize folder path for consistent keys */
private normalizePath(folderPath: string): string {
return resolve(folderPath);
}
/** Get or create folder permissions */
private getOrCreateFolder(folderPath: string): FolderPermissions {
const key = this.normalizePath(folderPath);
if (!this.settings.folders[key]) {
this.settings.folders[key] = {
path: key,
permissions: [],
lastAccessed: new Date().toISOString(),
};
this.dirty = true;
}
return this.settings.folders[key];
}
/** Add a permission for a folder */
addPermission(folderPath: string, pattern: string, action: PermissionAction): void {
const folder = this.getOrCreateFolder(folderPath);
// Remove existing permission for same pattern
folder.permissions = folder.permissions.filter(p => p.pattern !== pattern);
// Add new permission
folder.permissions.push({
pattern,
action,
createdAt: new Date().toISOString(),
});
folder.lastAccessed = new Date().toISOString();
this.dirty = true;
}
/** Remove a permission from a folder */
removePermission(folderPath: string, pattern: string): boolean {
const key = this.normalizePath(folderPath);
const folder = this.settings.folders[key];
if (!folder) return false;
const before = folder.permissions.length;
folder.permissions = folder.permissions.filter(p => p.pattern !== pattern);
if (folder.permissions.length !== before) {
this.dirty = true;
return true;
}
return false;
}
/** Get permissions for a folder */
getPermissions(folderPath: string): ToolPermission[] {
const key = this.normalizePath(folderPath);
const folder = this.settings.folders[key];
return folder?.permissions ?? [];
}
/** Check if a tool call is allowed for a folder */
checkPermission(folderPath: string, toolName: string, args?: string): PermissionAction | null {
const permissions = this.getPermissions(folderPath);
// Build the full pattern to match against
const callPattern = args ? `${toolName}(${args})` : toolName;
for (const perm of permissions) {
if (this.matchesPattern(callPattern, perm.pattern)) {
return perm.action;
}
}
return null; // No matching permission found
}
/** Check if a call pattern matches a permission pattern */
private matchesPattern(call: string, pattern: string): boolean {
// Convert permission pattern to regex
// * matches any characters, other chars are literal
const regexStr = pattern
.replace(/[.+?^${}()|[\]\\]/g, "\\$&") // Escape regex special chars except *
.replace(/\*/g, ".*"); // Convert * to .*
const regex = new RegExp(`^${regexStr}$`);
return regex.test(call);
}
/** Update last accessed time for a folder */
touchFolder(folderPath: string): void {
const key = this.normalizePath(folderPath);
const folder = this.settings.folders[key];
if (folder) {
folder.lastAccessed = new Date().toISOString();
this.dirty = true;
}
}
// ===========================================================================
// Global Settings
// ===========================================================================
/** Get global settings */
getGlobal(): GlobalSettings {
return { ...this.settings.global };
}
/** Update global settings */
updateGlobal(updates: Partial<GlobalSettings>): void {
this.settings.global = { ...this.settings.global, ...updates };
this.dirty = true;
}
/** Get a specific global setting */
getGlobalSetting<K extends keyof GlobalSettings>(key: K): GlobalSettings[K] {
return this.settings.global[key];
}
/** Set a specific global setting */
setGlobalSetting<K extends keyof GlobalSettings>(key: K, value: GlobalSettings[K]): void {
this.settings.global[key] = value;
this.dirty = true;
}
}
// =============================================================================
// Singleton Instance
// =============================================================================
let instance: SettingsManager | null = null;
/** Get the singleton settings manager */
export function getSettings(): SettingsManager {
if (!instance) {
instance = new SettingsManager();
}
return instance;
}
/** Save settings (convenience function) */
export function saveSettings(): void {
instance?.save();
}
/**
* Tests for clorchestra components
*
* Run with: bun test tests.ts
*/
import { describe, test, expect, beforeEach } from "bun:test";
import {
generateId,
generateTraceId,
generateSpanId,
nowISO,
truncate,
calculateRetryDelay,
isRetryableError,
permissionCacheKey,
dateDirName,
timestampedDirName,
DEFAULT_RETRY_CONFIG,
DEFAULT_MAX_CONCURRENT,
DEFAULT_PRIORITY,
type AgentState,
type SpawnAgentsConfig,
type SpawnAgentsOptions,
type ResumableState,
type SavedAgentState,
type SavedRunState,
} from "./types.js";
// =============================================================================
// types.ts tests
// =============================================================================
describe("types.ts", () => {
describe("generateId", () => {
test("generates unique IDs", () => {
const id1 = generateId();
const id2 = generateId();
expect(id1).not.toBe(id2);
});
test("includes prefix when provided", () => {
const id = generateId("agent");
expect(id.startsWith("agent-")).toBe(true);
});
test("generates IDs without prefix", () => {
const id = generateId();
expect(id.includes("-")).toBe(true);
});
});
describe("generateTraceId", () => {
test("generates 32-character hex string", () => {
const traceId = generateTraceId();
expect(traceId.length).toBe(32);
expect(/^[0-9a-f]{32}$/.test(traceId)).toBe(true);
});
test("generates unique trace IDs", () => {
const ids = new Set(Array.from({ length: 100 }, () => generateTraceId()));
expect(ids.size).toBe(100);
});
});
describe("generateSpanId", () => {
test("generates 16-character hex string", () => {
const spanId = generateSpanId();
expect(spanId.length).toBe(16);
expect(/^[0-9a-f]{16}$/.test(spanId)).toBe(true);
});
});
describe("nowISO", () => {
test("returns valid ISO 8601 timestamp", () => {
const timestamp = nowISO();
expect(() => new Date(timestamp)).not.toThrow();
expect(timestamp.endsWith("Z")).toBe(true);
});
});
describe("truncate", () => {
test("returns short strings unchanged", () => {
const short = "hello";
expect(truncate(short, 100)).toBe(short);
});
test("truncates long strings with ellipsis", () => {
const long = "a".repeat(300);
const result = truncate(long, 100);
expect(result.length).toBe(100);
expect(result.endsWith("...")).toBe(true);
});
test("uses default max length of 200", () => {
const long = "a".repeat(300);
const result = truncate(long);
expect(result.length).toBe(200);
});
});
describe("calculateRetryDelay", () => {
test("increases exponentially with attempts", () => {
const delay0 = calculateRetryDelay(0, { ...DEFAULT_RETRY_CONFIG, jitterFactor: 0 });
const delay1 = calculateRetryDelay(1, { ...DEFAULT_RETRY_CONFIG, jitterFactor: 0 });
const delay2 = calculateRetryDelay(2, { ...DEFAULT_RETRY_CONFIG, jitterFactor: 0 });
expect(delay1).toBe(delay0 * DEFAULT_RETRY_CONFIG.backoffMultiplier);
expect(delay2).toBe(delay1 * DEFAULT_RETRY_CONFIG.backoffMultiplier);
});
test("caps at maxDelayMs", () => {
const delay = calculateRetryDelay(100, { ...DEFAULT_RETRY_CONFIG, jitterFactor: 0 });
expect(delay).toBe(DEFAULT_RETRY_CONFIG.maxDelayMs);
});
test("adds jitter when jitterFactor > 0", () => {
// With jitter, delays for same attempt should vary
const delays = Array.from({ length: 10 }, () =>
calculateRetryDelay(1, DEFAULT_RETRY_CONFIG)
);
const uniqueDelays = new Set(delays);
expect(uniqueDelays.size).toBeGreaterThan(1);
});
});
describe("isRetryableError", () => {
test("returns true for rate limit errors", () => {
expect(isRetryableError(new Error("Rate limit exceeded"))).toBe(true);
expect(isRetryableError(new Error("rate limit"))).toBe(true);
});
test("returns true for timeout errors", () => {
expect(isRetryableError(new Error("Request timeout"))).toBe(true);
expect(isRetryableError(new Error("connection timeout"))).toBe(true);
});
test("returns true for network errors", () => {
expect(isRetryableError(new Error("ECONNRESET"))).toBe(true);
expect(isRetryableError(new Error("ECONNREFUSED"))).toBe(true);
expect(isRetryableError(new Error("Network error"))).toBe(true);
});
test("returns true for 5xx errors", () => {
expect(isRetryableError(new Error("502 Bad Gateway"))).toBe(true);
expect(isRetryableError(new Error("503 Service Unavailable"))).toBe(true);
expect(isRetryableError(new Error("504 Gateway Timeout"))).toBe(true);
});
test("returns false for non-retryable errors", () => {
expect(isRetryableError(new Error("Invalid request"))).toBe(false);
expect(isRetryableError(new Error("Authentication failed"))).toBe(false);
expect(isRetryableError(new Error("404 Not Found"))).toBe(false);
});
test("returns false for non-Error types", () => {
expect(isRetryableError("string error")).toBe(false);
expect(isRetryableError(null)).toBe(false);
expect(isRetryableError(undefined)).toBe(false);
});
});
describe("constants", () => {
test("DEFAULT_MAX_CONCURRENT is 10", () => {
expect(DEFAULT_MAX_CONCURRENT).toBe(10);
});
test("DEFAULT_PRIORITY is 10", () => {
expect(DEFAULT_PRIORITY).toBe(10);
});
});
describe("permissionCacheKey", () => {
test("generates consistent keys for same tool and input", () => {
const key1 = permissionCacheKey("Bash", { command: "npm test" });
const key2 = permissionCacheKey("Bash", { command: "npm test" });
expect(key1).toBe(key2);
});
test("groups Bash commands by command name", () => {
// All npm commands should get the same cache key
const key1 = permissionCacheKey("Bash", { command: "npm test" });
const key2 = permissionCacheKey("Bash", { command: "npm install" });
const key3 = permissionCacheKey("Bash", { command: "npm run build" });
expect(key1).toBe(key2);
expect(key2).toBe(key3);
// Different commands should get different keys
const gitKey = permissionCacheKey("Bash", { command: "git status" });
expect(gitKey).not.toBe(key1);
});
test("groups file operations by directory and extension", () => {
// Same directory and extension
const key1 = permissionCacheKey("Read", { file_path: "/src/foo.ts" });
const key2 = permissionCacheKey("Read", { file_path: "/src/bar.ts" });
expect(key1).toBe(key2);
// Different directory
const key3 = permissionCacheKey("Read", { file_path: "/lib/baz.ts" });
expect(key3).not.toBe(key1);
// Different extension
const key4 = permissionCacheKey("Read", { file_path: "/src/foo.js" });
expect(key4).not.toBe(key1);
});
test("differentiates between Read, Edit, Write for same file", () => {
const readKey = permissionCacheKey("Read", { file_path: "/src/foo.ts" });
const editKey = permissionCacheKey("Edit", { file_path: "/src/foo.ts" });
const writeKey = permissionCacheKey("Write", { file_path: "/src/foo.ts" });
// Different operations should have different keys
expect(readKey).not.toBe(editKey);
expect(editKey).not.toBe(writeKey);
});
test("groups search tools by pattern type", () => {
const key1 = permissionCacheKey("Grep", { pattern: "foo", path: "/src" });
const key2 = permissionCacheKey("Grep", { pattern: "bar", path: "/src" });
expect(key1).toBe(key2);
// Different path
const key3 = permissionCacheKey("Grep", { pattern: "foo", path: "/lib" });
expect(key3).not.toBe(key1);
});
test("groups web tools by tool name", () => {
const key1 = permissionCacheKey("WebSearch", { query: "foo" });
const key2 = permissionCacheKey("WebSearch", { query: "bar" });
expect(key1).toBe(key2);
const fetchKey = permissionCacheKey("WebFetch", { url: "http://example.com" });
expect(fetchKey).not.toBe(key1);
});
});
});
// =============================================================================
// Priority Queue tests (inline implementation for testing)
// =============================================================================
interface QueuedAgent {
agentId: string;
runId: string;
priority: number;
queuedAt: number;
}
class PriorityQueue {
private heap: QueuedAgent[] = [];
get length(): number {
return this.heap.length;
}
push(item: QueuedAgent): void {
this.heap.push(item);
this.bubbleUp(this.heap.length - 1);
}
pop(): QueuedAgent | undefined {
if (this.heap.length === 0) return undefined;
const top = this.heap[0];
const last = this.heap.pop()!;
if (this.heap.length > 0) {
this.heap[0] = last;
this.bubbleDown(0);
}
return top;
}
peek(): QueuedAgent | undefined {
return this.heap[0];
}
remove(agentId: string): boolean {
const idx = this.heap.findIndex(item => item.agentId === agentId);
if (idx === -1) return false;
const last = this.heap.pop()!;
if (idx < this.heap.length) {
this.heap[idx] = last;
this.bubbleUp(idx);
this.bubbleDown(idx);
}
return true;
}
updatePriority(agentId: string, newPriority: number): boolean {
const idx = this.heap.findIndex(item => item.agentId === agentId);
if (idx === -1) return false;
this.heap[idx].priority = newPriority;
this.bubbleUp(idx);
this.bubbleDown(idx);
return true;
}
private compare(a: QueuedAgent, b: QueuedAgent): number {
if (a.priority !== b.priority) return a.priority - b.priority;
return a.queuedAt - b.queuedAt;
}
private bubbleUp(idx: number): void {
while (idx > 0) {
const parent = Math.floor((idx - 1) / 2);
if (this.compare(this.heap[idx], this.heap[parent]) >= 0) break;
[this.heap[idx], this.heap[parent]] = [this.heap[parent], this.heap[idx]];
idx = parent;
}
}
private bubbleDown(idx: number): void {
while (true) {
const left = 2 * idx + 1;
const right = 2 * idx + 2;
let smallest = idx;
if (left < this.heap.length && this.compare(this.heap[left], this.heap[smallest]) < 0) {
smallest = left;
}
if (right < this.heap.length && this.compare(this.heap[right], this.heap[smallest]) < 0) {
smallest = right;
}
if (smallest === idx) break;
[this.heap[idx], this.heap[smallest]] = [this.heap[smallest], this.heap[idx]];
idx = smallest;
}
}
}
describe("PriorityQueue", () => {
let queue: PriorityQueue;
beforeEach(() => {
queue = new PriorityQueue();
});
describe("push and pop", () => {
test("pops items in priority order (lower first)", () => {
queue.push({ agentId: "a", runId: "r", priority: 10, queuedAt: 1 });
queue.push({ agentId: "b", runId: "r", priority: 5, queuedAt: 2 });
queue.push({ agentId: "c", runId: "r", priority: 15, queuedAt: 3 });
expect(queue.pop()?.agentId).toBe("b"); // priority 5
expect(queue.pop()?.agentId).toBe("a"); // priority 10
expect(queue.pop()?.agentId).toBe("c"); // priority 15
});
test("FIFO within same priority", () => {
queue.push({ agentId: "a", runId: "r", priority: 10, queuedAt: 1 });
queue.push({ agentId: "b", runId: "r", priority: 10, queuedAt: 2 });
queue.push({ agentId: "c", runId: "r", priority: 10, queuedAt: 3 });
expect(queue.pop()?.agentId).toBe("a");
expect(queue.pop()?.agentId).toBe("b");
expect(queue.pop()?.agentId).toBe("c");
});
test("returns undefined when empty", () => {
expect(queue.pop()).toBeUndefined();
});
});
describe("peek", () => {
test("returns highest priority item without removing", () => {
queue.push({ agentId: "a", runId: "r", priority: 10, queuedAt: 1 });
queue.push({ agentId: "b", runId: "r", priority: 5, queuedAt: 2 });
expect(queue.peek()?.agentId).toBe("b");
expect(queue.length).toBe(2);
});
test("returns undefined when empty", () => {
expect(queue.peek()).toBeUndefined();
});
});
describe("remove", () => {
test("removes item by agentId", () => {
queue.push({ agentId: "a", runId: "r", priority: 10, queuedAt: 1 });
queue.push({ agentId: "b", runId: "r", priority: 5, queuedAt: 2 });
queue.push({ agentId: "c", runId: "r", priority: 15, queuedAt: 3 });
expect(queue.remove("b")).toBe(true);
expect(queue.length).toBe(2);
expect(queue.pop()?.agentId).toBe("a");
expect(queue.pop()?.agentId).toBe("c");
});
test("returns false if item not found", () => {
queue.push({ agentId: "a", runId: "r", priority: 10, queuedAt: 1 });
expect(queue.remove("nonexistent")).toBe(false);
});
});
describe("updatePriority", () => {
test("updates priority and reorders", () => {
queue.push({ agentId: "a", runId: "r", priority: 10, queuedAt: 1 });
queue.push({ agentId: "b", runId: "r", priority: 5, queuedAt: 2 });
queue.push({ agentId: "c", runId: "r", priority: 15, queuedAt: 3 });
// Boost "c" to highest priority
queue.updatePriority("c", 1);
expect(queue.pop()?.agentId).toBe("c"); // now priority 1
expect(queue.pop()?.agentId).toBe("b"); // priority 5
expect(queue.pop()?.agentId).toBe("a"); // priority 10
});
test("returns false if item not found", () => {
expect(queue.updatePriority("nonexistent", 1)).toBe(false);
});
});
describe("length", () => {
test("tracks queue size", () => {
expect(queue.length).toBe(0);
queue.push({ agentId: "a", runId: "r", priority: 10, queuedAt: 1 });
expect(queue.length).toBe(1);
queue.push({ agentId: "b", runId: "r", priority: 5, queuedAt: 2 });
expect(queue.length).toBe(2);
queue.pop();
expect(queue.length).toBe(1);
});
});
describe("stress test", () => {
test("handles many items correctly", () => {
const items = Array.from({ length: 1000 }, (_, i) => ({
agentId: `agent-${i}`,
runId: "r",
priority: Math.floor(Math.random() * 100),
queuedAt: i,
}));
for (const item of items) {
queue.push(item);
}
expect(queue.length).toBe(1000);
// Verify items come out in priority order
let prevPriority = -1;
let prevQueuedAt = -1;
while (queue.length > 0) {
const item = queue.pop()!;
if (item.priority === prevPriority) {
expect(item.queuedAt).toBeGreaterThan(prevQueuedAt);
} else {
expect(item.priority).toBeGreaterThanOrEqual(prevPriority);
}
prevPriority = item.priority;
prevQueuedAt = item.queuedAt;
}
});
});
});
// =============================================================================
// Logging tests
// =============================================================================
import { JSONLLogger, LoggerContext } from "./logging.js";
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
describe("logging.ts", () => {
const testDir = join(tmpdir(), `clorchestra-test-${Date.now()}`);
beforeEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true });
}
mkdirSync(testDir, { recursive: true });
});
describe("JSONLLogger", () => {
test("creates log file and writes events", async () => {
const logPath = join(testDir, "test.jsonl");
const logger = new JSONLLogger({
filePath: logPath,
service: "test",
});
logger.write({
timestamp: nowISO(),
event_type: "agent.created",
level: "info",
trace_id: "abc",
span_id: "def",
service: "test",
});
await logger.close();
expect(existsSync(logPath)).toBe(true);
const content = readFileSync(logPath, "utf-8");
const lines = content.trim().split("\n");
expect(lines.length).toBe(1);
const event = JSON.parse(lines[0]);
expect(event.event_type).toBe("agent.created");
expect(event.service).toBe("test");
});
test("respects minLevel filter", async () => {
const logPath = join(testDir, "level-test.jsonl");
const logger = new JSONLLogger({
filePath: logPath,
service: "test",
minLevel: "warn",
});
logger.write({ timestamp: nowISO(), event_type: "agent.created", level: "debug", trace_id: "a", span_id: "b", service: "t" });
logger.write({ timestamp: nowISO(), event_type: "agent.created", level: "info", trace_id: "a", span_id: "b", service: "t" });
logger.write({ timestamp: nowISO(), event_type: "agent.created", level: "warn", trace_id: "a", span_id: "b", service: "t" });
logger.write({ timestamp: nowISO(), event_type: "agent.created", level: "error", trace_id: "a", span_id: "b", service: "t" });
await logger.close();
const content = readFileSync(logPath, "utf-8");
const lines = content.trim().split("\n");
expect(lines.length).toBe(2); // Only warn and error
});
test("creates nested directories", async () => {
const logPath = join(testDir, "nested", "deep", "test.jsonl");
const logger = new JSONLLogger({
filePath: logPath,
service: "test",
});
logger.write({ timestamp: nowISO(), event_type: "agent.created", level: "info", trace_id: "a", span_id: "b", service: "t" });
await logger.close();
expect(existsSync(logPath)).toBe(true);
});
});
describe("LoggerContext", () => {
test("logs agent lifecycle events", async () => {
const logPath = join(testDir, "context-test.jsonl");
const logger = new JSONLLogger({
filePath: logPath,
service: "test",
});
const ctx = logger.child({ agentId: "agent-1", agentName: "test-agent" });
ctx.agentCreated({ itemId: "item-1", provider: "anthropic", model: "sonnet", depth: 1, context: "test" });
ctx.agentInvoked({ prompt: "test prompt" });
ctx.agentCompleted({ status: "success", durationMs: 1000, inputTokens: 100, outputTokens: 200 });
await logger.close();
const content = readFileSync(logPath, "utf-8");
const lines = content.trim().split("\n").map(l => JSON.parse(l));
expect(lines.length).toBe(3);
expect(lines[0].event_type).toBe("agent.created");
expect(lines[1].event_type).toBe("agent.invoked");
expect(lines[2].event_type).toBe("agent.completed");
// Check agent ID is included
expect(lines[0]["gen_ai.agent.id"]).toBe("agent-1");
});
test("creates child contexts with parent span", async () => {
const logPath = join(testDir, "child-test.jsonl");
const logger = new JSONLLogger({
filePath: logPath,
service: "test",
});
const parentCtx = logger.child({ agentId: "parent" });
const childCtx = parentCtx.child();
parentCtx.agentCreated({ itemId: "i", provider: "p", model: "m", depth: 1 });
childCtx.toolCalled({ toolName: "Read", callId: "call-1" });
await logger.close();
const content = readFileSync(logPath, "utf-8");
const lines = content.trim().split("\n").map(l => JSON.parse(l));
// Child should have parent_span_id
expect(lines[1].parent_span_id).toBe(lines[0].span_id);
});
});
});
// =============================================================================
// PermissionResult type validation tests
// =============================================================================
describe("PermissionResult validation", () => {
// These tests validate that our permission results match SDK expectations
test("allow result has required fields", () => {
const input = { command: "npm test" };
const result = { behavior: "allow" as const, updatedInput: input };
expect(result.behavior).toBe("allow");
expect(result.updatedInput).toBeDefined();
expect(typeof result.updatedInput).toBe("object");
});
test("deny result has required fields", () => {
const result = { behavior: "deny" as const, message: "User denied" };
expect(result.behavior).toBe("deny");
expect(result.message).toBeDefined();
expect(typeof result.message).toBe("string");
});
test("allow result must include updatedInput", () => {
// This is the bug we just fixed - allow requires updatedInput
const input = { file_path: "/test" };
const result = { behavior: "allow" as const, updatedInput: input };
// Verify the structure matches what SDK expects
expect("updatedInput" in result).toBe(true);
expect(result.updatedInput).toEqual(input);
});
test("deny result must include message", () => {
// This is required by the SDK
const result = { behavior: "deny" as const, message: "Access denied" };
expect("message" in result).toBe(true);
expect(result.message.length).toBeGreaterThan(0);
});
});
// =============================================================================
// Resume state tests
// =============================================================================
describe("Resume state types", () => {
test("ResumableState has required fields", () => {
const state: ResumableState = {
version: 1,
sessionId: "test-session",
dateDir: "2024-12-15",
sessionDir: "2024-12-15-1430-test-session",
runId: "main-abc123",
logDir: "/path/to/logs",
startedAt: "2024-12-15T14:30:00Z",
lastUpdatedAt: "2024-12-15T14:35:00Z",
status: "running",
promptHierarchy: ["Main task"],
runs: {},
stats: {
tokens: { input: 1000, output: 500, cacheRead: 200, cacheWrite: 50 },
costUsd: 0.05,
turns: 10,
},
config: {
model: "sonnet",
maxConcurrent: 10,
defaultDepth: 1,
},
};
expect(state.version).toBe(1);
expect(state.sessionId).toBe("test-session");
expect(state.status).toBe("running");
});
test("SavedAgentState tracks fyiInjected", () => {
const agent: SavedAgentState = {
id: "agent-123",
name: "agent-task1",
itemId: "task1",
itemIndex: 0,
status: "completed",
priority: 10,
depth: 1,
result: "Success",
fyiInjected: true,
};
expect(agent.fyiInjected).toBe(true);
expect(agent.status).toBe("completed");
});
test("SavedRunState contains items for re-queueing", () => {
const run: SavedRunState = {
runId: "run-123",
context: "Shared context",
items: [
{ prompt: "Task 1" },
{ prompt: "Task 2", priority: 5 },
],
agents: {
"agent-1": {
id: "agent-1",
name: "agent-task1",
itemId: "t1",
itemIndex: 0,
status: "completed",
priority: 10,
depth: 1,
fyiInjected: true,
},
"agent-2": {
id: "agent-2",
name: "agent-task2",
itemId: "t2",
itemIndex: 1,
status: "queued", // This agent should be re-queued on resume
priority: 5,
depth: 1,
fyiInjected: false,
},
},
startedAt: "2024-12-15T14:30:00Z",
};
expect(run.items.length).toBe(2);
expect(Object.keys(run.agents).length).toBe(2);
// Agent 2 should be re-queued on resume
const agent2 = run.agents["agent-2"];
expect(agent2.status).toBe("queued");
expect(agent2.itemIndex).toBe(1);
expect(run.items[agent2.itemIndex].priority).toBe(5);
});
});
describe("Timestamp utilities", () => {
test("dateDirName generates YYYY-MM-DD format", () => {
const date = new Date("2024-03-15T10:30:00Z");
const result = dateDirName(date);
expect(result).toBe("2024-03-15");
});
test("timestampedDirName generates YYYY-MM-DD-HHmm-suffix format", () => {
const date = new Date("2024-03-15T10:30:00Z");
// Note: This will use local timezone for hours/minutes
const result = timestampedDirName("mysession", date);
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}-\d{4}-mysession$/);
expect(result).toContain("mysession");
});
});
describe("Agent hierarchy", () => {
test("AgentState includes parentAgentId for hierarchy tracking", () => {
const agentState: AgentState = {
id: "agent-child",
name: "child-agent",
itemId: "item-1",
itemIndex: 0,
status: "queued",
logFile: "/logs/agent.jsonl",
depth: 0,
parentAgentId: "agent-parent",
priority: 10,
retryAttempt: 0,
queuedAt: new Date().toISOString(),
};
expect(agentState.parentAgentId).toBe("agent-parent");
expect(agentState.depth).toBe(0);
});
test("SavedAgentState preserves parentAgentId for resume", () => {
const savedState: SavedAgentState = {
id: "agent-1",
name: "test-agent",
itemId: "item-1",
itemIndex: 0,
status: "completed",
priority: 5,
depth: 1,
parentAgentId: "parent-agent-id",
result: "done",
fyiInjected: true,
};
expect(savedState.parentAgentId).toBe("parent-agent-id");
});
test("SpawnAgentsOptions supports parentAgentId", () => {
const options: SpawnAgentsOptions = {
model: "haiku",
depth: 0,
parentAgentId: "calling-agent-id",
};
expect(options.parentAgentId).toBe("calling-agent-id");
});
test("AgentInfo includes depth and parentAgentId for tree building", () => {
// This tests the interface shape expected by interactive.tsx
interface AgentInfo {
id: string;
name: string;
status: "queued" | "running" | "completed" | "failed" | "cancelled";
parentAgentId?: string;
depth: number;
}
const agents: AgentInfo[] = [
{ id: "root-1", name: "root-agent", status: "running", depth: 1 },
{ id: "child-1", name: "child-agent", status: "queued", parentAgentId: "root-1", depth: 0 },
{ id: "grandchild-1", name: "grandchild", status: "completed", parentAgentId: "child-1", depth: 0 },
];
// Test tree structure can be built from flat list
const rootAgents = agents.filter(a => !a.parentAgentId);
expect(rootAgents.length).toBe(1);
expect(rootAgents[0].id).toBe("root-1");
// Test children can be found
const children = agents.filter(a => a.parentAgentId === "root-1");
expect(children.length).toBe(1);
expect(children[0].id).toBe("child-1");
// Test grandchildren
const grandchildren = agents.filter(a => a.parentAgentId === "child-1");
expect(grandchildren.length).toBe(1);
expect(grandchildren[0].id).toBe("grandchild-1");
});
test("Navigable agents filter: pending/active or top-level", () => {
interface AgentInfo {
id: string;
name: string;
status: "queued" | "running" | "completed" | "failed" | "cancelled";
parentAgentId?: string;
depth: number;
}
const agents: AgentInfo[] = [
{ id: "root-1", name: "root-done", status: "completed", depth: 1 },
{ id: "root-2", name: "root-running", status: "running", depth: 1 },
{ id: "child-1", name: "child-queued", status: "queued", parentAgentId: "root-1", depth: 0 },
{ id: "child-2", name: "child-done", status: "completed", parentAgentId: "root-1", depth: 0 },
];
// Navigable = pending/active OR top-level (no parent)
const navigable = agents.filter(a =>
a.status === "queued" || a.status === "running" || !a.parentAgentId
);
// root-1 (top-level), root-2 (running), child-1 (queued)
expect(navigable.length).toBe(3);
expect(navigable.map(a => a.id).sort()).toEqual(["child-1", "root-1", "root-2"]);
// child-2 is not navigable (completed + has parent)
expect(navigable.find(a => a.id === "child-2")).toBeUndefined();
});
});
// =============================================================================
// Run all tests
// =============================================================================
console.log("Running tests...");
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"resolveJsonModule": true,
"jsx": "react-jsx"
},
"include": [
"cli.ts",
"types.ts",
"logging.ts",
"agent-manager.ts",
"mcp-tools.ts",
"orchestrator.ts",
"interactive.tsx"
],
"exclude": ["node_modules", "dist", "intermediate-outputs"]
}
/**
* Core types for clo map-reduce orchestration
*/
import { homedir } from "node:os";
import { join } from "node:path";
// =============================================================================
// Constants
// =============================================================================
/** Default log directory: ~/.clo/runs/<session-id>/ */
export const DEFAULT_LOG_BASE = join(homedir(), ".clo", "runs");
/** Default max concurrent agents */
export const DEFAULT_MAX_CONCURRENT = 10;
/** Default agent depth (1 = can spawn subagents, 0 = leaf agent) */
export const DEFAULT_AGENT_DEPTH = 1;
/** Default priority (lower = higher priority, 0 = highest) */
export const DEFAULT_PRIORITY = 10;
/** Default timeout per agent (ms) - 5 minutes */
export const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
// =============================================================================
// Permission Fusion
// =============================================================================
/**
* A permission decision that can be cached and shared across agents.
* Key is generated from tool name + normalized input.
*/
export interface PermissionDecision {
/** Whether permission was granted */
granted: boolean;
/** "allow" = always allow this tool, "deny" = always deny */
behavior?: "allow" | "deny";
/** When this decision was made */
timestamp: string;
/** How many agents have used this cached decision */
useCount: number;
}
/**
* Generate a cache key for a permission request.
* Normalizes the input to ensure equivalent requests get the same key.
*/
export function permissionCacheKey(toolName: string, input: unknown): string {
// For certain tools, we can be smarter about what makes requests "equivalent"
const normalizedInput = normalizeToolInput(toolName, input);
return `${toolName}:${JSON.stringify(normalizedInput)}`;
}
/**
* Normalize tool input for permission caching.
* For some tools, we want to cache at a coarser granularity.
*/
function normalizeToolInput(toolName: string, input: unknown): unknown {
if (!input || typeof input !== "object") return input;
const obj = input as Record<string, unknown>;
switch (toolName) {
case "Bash":
// For Bash, cache by command prefix (first word or pattern)
if (typeof obj.command === "string") {
const cmd = obj.command.trim();
// Extract command name (first word before space or flags)
const match = cmd.match(/^(\S+)/);
const cmdName = match ? match[1] : cmd;
// Group by command type
return { commandType: cmdName };
}
break;
case "Read":
case "Edit":
case "Write":
// For file operations, cache by directory or file extension
if (typeof obj.file_path === "string") {
const path = obj.file_path;
// Get directory and extension
const lastSlash = path.lastIndexOf("/");
const dir = lastSlash > 0 ? path.substring(0, lastSlash) : ".";
const ext = path.includes(".") ? path.substring(path.lastIndexOf(".")) : "";
return { dir, ext, tool: toolName };
}
break;
case "Glob":
case "Grep":
// For search tools, cache by pattern type
if (typeof obj.pattern === "string") {
return { patternType: "search", path: obj.path };
}
break;
case "WebFetch":
case "WebSearch":
// For web tools, cache by domain or query type
return { tool: toolName };
}
return input;
}
// =============================================================================
// Agent Status and Lifecycle
// =============================================================================
export type AgentStatus = "queued" | "running" | "completed" | "failed" | "cancelled";
export interface AgentState {
id: string;
name: string;
itemId: string;
/** Index into the items array for reliable lookup */
itemIndex: number;
status: AgentStatus;
logFile: string;
depth: number;
/** Parent agent ID for hierarchy tracking (null for top-level agents) */
parentAgentId?: string;
/** Priority: lower = runs first (0 = highest) */
priority: number;
/** Position in queue (updated dynamically) */
queuePosition?: number;
/** Timeout in ms */
timeoutMs?: number;
/** Retry configuration */
retryConfig?: RetryConfig;
/** Current retry attempt (0 = first attempt) */
retryAttempt: number;
/** Last error (for retry tracking) */
lastError?: string;
startedAt?: string;
completedAt?: string;
queuedAt: string;
result?: string;
error?: string;
turns?: number;
costUsd?: number;
durationMs?: number;
usage?: TokenUsage;
/** Whether FYI for this agent was injected to main loop */
fyiInjected?: boolean;
}
export interface TokenUsage {
inputTokens: number;
outputTokens: number;
}
// =============================================================================
// Run State
// =============================================================================
export interface RunState {
runId: string;
sessionId: string;
logDir: string;
agents: Map<string, AgentState>;
startedAt: string;
completedAt?: string;
config: SpawnAgentsConfig;
}
/** Aggregate statistics for a run or session */
export interface RunStats {
sessionId: string;
runId: string;
startedAt: string;
completedAt?: string;
durationMs: number;
agents: {
total: number;
completed: number;
failed: number;
cancelled: number;
};
tokens: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
costUsd: number;
turns: number;
}
/** Saved agent state for resume */
export interface SavedAgentState {
id: string;
name: string;
itemId: string;
itemIndex: number;
status: AgentStatus;
priority: number;
depth: number;
parentAgentId?: string;
result?: string;
error?: string;
fyiInjected: boolean;
startedAt?: string;
completedAt?: string;
}
/** Saved run state for resume */
export interface SavedRunState {
runId: string;
context: string;
items: SpawnAgentsItem[];
options?: SpawnAgentsOptions;
agents: Record<string, SavedAgentState>;
startedAt: string;
completedAt?: string;
}
/** Full resumable state persisted to state.json */
export interface ResumableState {
version: 1;
sessionId: string;
dateDir: string;
sessionDir: string;
runId: string;
logDir: string;
startedAt: string;
lastUpdatedAt: string;
status: "running" | "completed" | "failed";
promptHierarchy: string[];
runs: Record<string, SavedRunState>;
stats: {
tokens: { input: number; output: number; cacheRead: number; cacheWrite: number };
costUsd: number;
turns: number;
};
config: {
model?: string;
maxConcurrent: number;
defaultDepth: number;
};
}
// =============================================================================
// SpawnAgents Tool
// =============================================================================
export interface SpawnAgentsItem {
/** Unique identifier (auto-generated if missing) */
id?: string;
/** The prompt for this specific agent */
prompt: string;
/** Optional data payload (passed to agent as context) */
data?: unknown;
/** Priority for this specific item (overrides default) */
priority?: number;
/** Timeout for this specific item in ms (overrides default) */
timeoutMs?: number;
/** Retry config for this specific item (overrides default) */
retryConfig?: RetryConfig;
}
export interface SpawnAgentsConfig {
context: string;
items: SpawnAgentsItem[];
options?: SpawnAgentsOptions;
}
export interface SpawnAgentsOptions {
model?: "sonnet" | "opus" | "haiku";
maxTurns?: number;
maxBudgetUsd?: number;
allowedTools?: string[];
disallowedTools?: string[];
maxConcurrent?: number;
/** Agent depth: > 0 allows spawning subagents, 0 = leaf */
depth?: number;
/** Default priority for all items (lower = runs first) */
priority?: number;
/** Default timeout in ms for all items */
timeoutMs?: number;
/** Default retry configuration */
retryConfig?: RetryConfig;
/** Parent agent ID for hierarchy tracking (set automatically by nested agents) */
parentAgentId?: string;
}
export interface SpawnAgentsResult {
runId: string;
agentCount: number;
logDir: string;
/** IDs of all spawned agents */
agentIds: string[];
}
// =============================================================================
// ListAgents Tool
// =============================================================================
export interface ListAgentsInput {
runId: string;
/** Only return queued agents */
queuedOnly?: boolean;
/** Filter by status */
status?: AgentStatus;
}
export interface ListAgentsResult {
agents: AgentState[];
summary: {
total: number;
queued: number;
running: number;
completed: number;
failed: number;
cancelled: number;
};
}
// =============================================================================
// CancelSubagents Tool
// =============================================================================
export interface CancelSubagentsInput {
/** Agent IDs to cancel */
ids: string[];
/** Optional: cancel all agents in a run */
runId?: string;
}
export interface CancelSubagentsResult {
cancelled: string[];
notFound: string[];
alreadyFinished: string[];
}
// =============================================================================
// SetSubagentPriority Tool
// =============================================================================
export interface SetSubagentPriorityInput {
/** Agent ID to update */
id: string;
/** New priority (lower = runs first) */
priority: number;
}
export interface SetSubagentPriorityResult {
id: string;
oldPriority: number;
newPriority: number;
newQueuePosition?: number;
}
// =============================================================================
// FYI Messages
// =============================================================================
export interface FYIMessage {
agentId: string;
agentName: string;
itemId: string;
status: "completed" | "failed" | "cancelled";
content: string;
durationMs?: number;
costUsd?: number;
turns?: number;
retryAttempt?: number;
}
// =============================================================================
// Question Forwarding (Structured Output Protocol)
// =============================================================================
export interface SubAgentNeedsInput {
type: "needs_input";
requestId: string;
question: {
text: string;
options?: Array<{ label: string; description: string }>;
multiSelect?: boolean;
};
urgency: "blocking" | "can_continue";
}
// =============================================================================
// JSONL Logging (OpenTelemetry GenAI conventions)
// =============================================================================
export type LogLevel = "debug" | "info" | "warn" | "error";
export type LogEventType =
| "agent.created"
| "agent.queued"
| "agent.started"
| "agent.invoked"
| "agent.completed"
| "agent.failed"
| "agent.cancelled"
| "agent.retry"
| "agent.error"
| "tool.called"
| "tool.result"
| "message.received"
| "fyi.injected";
export interface BaseLogEvent {
timestamp: string;
event_type: LogEventType;
level: LogLevel;
trace_id: string;
span_id: string;
parent_span_id?: string;
service: string;
"gen_ai.agent.id"?: string;
"gen_ai.agent.name"?: string;
}
// =============================================================================
// Retry Configuration
// =============================================================================
export interface RetryConfig {
/** Max retry attempts (0 = no retries) */
maxRetries: number;
/** Base delay between retries in ms */
baseDelayMs: number;
/** Maximum delay between retries in ms */
maxDelayMs: number;
/** Multiplier for exponential backoff */
backoffMultiplier: number;
/** Jitter factor (0-1) to randomize delays */
jitterFactor: number;
}
export const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxRetries: 3,
baseDelayMs: 1000,
maxDelayMs: 30000,
backoffMultiplier: 2,
jitterFactor: 0.2,
};
// =============================================================================
// Permission Modes (matching Claude CLI)
// =============================================================================
export type PermissionMode =
| "default"
| "acceptEdits"
| "bypassPermissions"
| "plan"
| "dontAsk";
// =============================================================================
// Configuration
// =============================================================================
export interface OrchestratorConfig {
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 (clorchestra level) */
debug: boolean;
/** Pass --debug to Claude SDK */
debugSdk: boolean;
}
export const DEFAULT_CONFIG: Partial<OrchestratorConfig> = {
logBaseDir: DEFAULT_LOG_BASE,
maxConcurrent: DEFAULT_MAX_CONCURRENT,
defaultDepth: DEFAULT_AGENT_DEPTH,
defaultPriority: DEFAULT_PRIORITY,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
defaultRetryConfig: DEFAULT_RETRY_CONFIG,
interactive: true,
permissionMode: "acceptEdits",
allowedTools: ["Read", "Edit", "Glob", "Grep", "Bash", "WebSearch"],
compactReminder: true,
debug: false,
debugSdk: false,
};
// =============================================================================
// Utility Functions
// =============================================================================
export function generateId(prefix: string = ""): string {
const ts = Date.now().toString(36);
const rand = Math.random().toString(36).substring(2, 8);
return prefix ? `${prefix}-${ts}-${rand}` : `${ts}-${rand}`;
}
export function generateTraceId(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
}
export function generateSpanId(): string {
const bytes = new Uint8Array(8);
crypto.getRandomValues(bytes);
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
}
export function nowISO(): string {
return new Date().toISOString();
}
/** Generate a date folder name: YYYY-MM-DD */
export function dateDirName(date: Date = new Date()): string {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
/** Generate a timestamped directory name: YYYY-MM-DD-HHmm-{suffix} */
export function timestampedDirName(suffix: string, date: Date = new Date()): string {
const dateStr = dateDirName(date);
const hh = String(date.getHours()).padStart(2, "0");
const min = String(date.getMinutes()).padStart(2, "0");
return `${dateStr}-${hh}${min}-${suffix}`;
}
export function truncate(str: string, max: number = 200): string {
return str.length <= max ? str : str.substring(0, max - 3) + "...";
}
/** Calculate retry delay with exponential backoff and jitter */
export function calculateRetryDelay(attempt: number, config: RetryConfig): number {
const exponentialDelay = Math.min(
config.baseDelayMs * Math.pow(config.backoffMultiplier, attempt),
config.maxDelayMs
);
const jitter = exponentialDelay * config.jitterFactor * Math.random();
return exponentialDelay + jitter;
}
/** Check if an error is retryable */
export function isRetryableError(error: unknown): boolean {
if (error instanceof Error) {
const msg = error.message.toLowerCase();
return (
msg.includes("rate limit") ||
msg.includes("timeout") ||
msg.includes("connection") ||
msg.includes("network") ||
msg.includes("econnreset") ||
msg.includes("econnrefused") ||
msg.includes("503") ||
msg.includes("502") ||
msg.includes("504") ||
msg.includes("529")
);
}
return false;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment