import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import log from "electron-log/main.js"; import { z } from "zod"; import { agentMessageInputSchema, approvalInputSchema, goalInputSchema, hireRequestSchema, projectInputSchema, taskInputSchema } from "@shared/contracts"; import { validateAgentToken, type TokenPayload } from "./agent-auth"; import { canAssignTask, canControlAgent, canManageGoal, canManageOrganization, canManageProject, canUpdateTask } from "./agent-permissions"; import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy"; import type { AppDatabase } from "./database"; import { dispatchAgentMessage } from "./message-dispatch"; import type { DesktopEvent } from "@shared/types"; type RequestHandler = ( req: IncomingMessage, res: ServerResponse, params: Record, token: TokenPayload, body: unknown, ) => void | Promise; interface Route { method: string; pattern: RegExp; paramNames: string[]; handler: RequestHandler; } const taskUpdateSchema = z.object({ status: z.enum(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]).optional(), comment: z.string().min(1).max(4000).optional(), title: z.string().min(2).max(160).optional(), description: z.string().max(4000).optional(), assigneeAgentId: z.string().uuid().nullable().optional(), priority: z.enum(["critical", "high", "medium", "low"]).optional(), }); const checkoutSchema = z.object({ agentId: z.string().uuid().optional(), }); const approvalListQuerySchema = z.object({ requestedByAgentId: z.string().uuid().optional(), relatedTaskId: z.string().uuid().optional(), status: z.string().optional(), }); export interface ApiServerEventCallbacks { onDomainChanged: () => void; onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null; onTaskCreated: (taskId: string, companyId: string) => void; onTaskStatusChanged: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void; onCommentPosted: (taskId: string, companyId: string, authorAgentId: string | null) => void; onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void; onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null) => void; onAutoAssignTask: (taskId: string, companyId: string) => string | null; emitEvent: (event: DesktopEvent) => void; } export class AgentApiServer { private routes: Route[] = []; private server: ReturnType | null = null; private port = 0; private db: AppDatabase; private onDomainChanged: () => void; private onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null; private emitEvent: (event: DesktopEvent) => void; private callbacks: ApiServerEventCallbacks; private allowedOrigins: Set; private onBrowserAction: ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record) => void) | null = null; constructor( db: AppDatabase, onDomainChanged: () => void, onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null, callbacks?: Partial, allowedOrigins: Iterable = ["app://-"], ) { this.db = db; this.onDomainChanged = onDomainChanged; this.onWakeAgent = onWakeAgent; this.emitEvent = callbacks?.emitEvent ?? (() => {}); this.allowedOrigins = new Set(Array.from(allowedOrigins).filter(Boolean)); this.callbacks = { onDomainChanged, onWakeAgent, onTaskCreated: callbacks?.onTaskCreated ?? (() => {}), onTaskStatusChanged: callbacks?.onTaskStatusChanged ?? (() => {}), onCommentPosted: callbacks?.onCommentPosted ?? (() => {}), onApprovalResolved: callbacks?.onApprovalResolved ?? (() => {}), onDocumentCreated: callbacks?.onDocumentCreated ?? (() => {}), onAutoAssignTask: callbacks?.onAutoAssignTask ?? (() => null), emitEvent: callbacks?.emitEvent ?? (() => {}), }; this.registerRoutes(); } setBrowserActionHandler(handler: (actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record) => void) { this.onBrowserAction = handler; } getPort(): number { return this.port; } async start(): Promise { return new Promise((resolve, reject) => { this.server = createServer((req, res) => void this.handleRequest(req, res)); this.server.listen(0, "127.0.0.1", () => { const addr = this.server!.address(); this.port = typeof addr === "object" && addr ? addr.port : 0; log.info(`[api] Agent API server listening on http://127.0.0.1:${this.port}`); resolve(this.port); }); this.server.on("error", reject); }); } stop() { this.server?.close(); this.server = null; } private addRoute(method: string, path: string, handler: RequestHandler) { const paramNames: string[] = []; const patternStr = path.replace(/:(\w+)/g, (_match, name: string) => { paramNames.push(name); return "([^/]+)"; }); this.routes.push({ method, pattern: new RegExp(`^${patternStr}$`), paramNames, handler }); } private async handleRequest(req: IncomingMessage, res: ServerResponse) { const origin = req.headers.origin; if (origin) { if (!this.allowedOrigins.has(origin)) { this.json(res, 403, { error: "Origin not allowed" }); return; } res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader("Vary", "Origin"); } res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Agent-Company-Run-Id"); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`); const pathname = url.pathname; const method = (req.method ?? "GET").toUpperCase(); const authHeader = req.headers.authorization; if (!authHeader?.startsWith("Bearer ")) { this.json(res, 401, { error: "Missing or invalid Authorization header" }); return; } const token = validateAgentToken(authHeader.slice(7)); if (!token) { this.json(res, 401, { error: "Invalid or expired token" }); return; } const runHeader = req.headers["x-agent-company-run-id"]; const claimedRunId = Array.isArray(runHeader) ? runHeader[0] : runHeader; if (typeof claimedRunId !== "string" || claimedRunId.trim() !== token.runId) { this.json(res, 401, { error: "Missing or mismatched X-Agent-Company-Run-Id header" }); return; } const runState = this.db.getRunAuthState(token.runId); if (!runState || runState.companyId !== token.companyId || runState.agentId !== token.agentId) { this.json(res, 403, { error: "Token references an unknown or mismatched run" }); return; } if (!["queued", "running"].includes(runState.status)) { this.json(res, 401, { error: "Run token is no longer active" }); return; } for (const route of this.routes) { if (route.method !== method) continue; const match = pathname.match(route.pattern); if (!match) continue; const params: Record = {}; route.paramNames.forEach((name, i) => { params[name] = match[i + 1]!; }); let body: unknown = null; if (["POST", "PATCH", "PUT"].includes(method)) { try { body = await this.readBody(req); } catch (bodyErr) { if (bodyErr instanceof Error && bodyErr.message === "Request body too large") { this.json(res, 413, { error: "Request body too large (max 1 MB)" }); } else { this.json(res, 400, { error: "Invalid JSON body" }); } return; } } try { await route.handler(req, res, params, token, body); } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error(`[api] ${method} ${pathname}: ${message}`); this.json(res, 500, { error: "Internal server error" }); } return; } this.json(res, 404, { error: "Not found" }); } private readBody(req: IncomingMessage): Promise { const MAX_BODY_BYTES = 1_048_576; // 1 MB return new Promise((resolve, reject) => { let data = ""; let bytes = 0; req.on("data", (chunk) => { bytes += Buffer.byteLength(chunk); if (bytes > MAX_BODY_BYTES) { req.destroy(); reject(new Error("Request body too large")); return; } data += String(chunk); }); req.on("end", () => { try { resolve(data ? JSON.parse(data) : null); } catch { reject(new Error("Invalid JSON")); } }); req.on("error", reject); }); } private json(res: ServerResponse, status: number, data: unknown) { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(data)); } private companyRefError( res: ServerResponse, companyId: string, refs: Array<{ id: string | null | undefined; table: "agents" | "goals" | "projects" | "tasks" | "workspaces" | "approvals"; label: string }>, ) { const invalid = refs.find((ref) => ref.id && !this.db.belongsToCompany(ref.table, ref.id, companyId)); if (!invalid) return false; this.json(res, 422, { error: `${invalid.label} does not belong to the current company` }); return true; } private registerRoutes() { this.addRoute("GET", "/api/agents/me", (_req, res, _params, token) => { const agent = this.db.getAgent(token.agentId); const chainOfCommand = this.db.getChainOfCommand(token.agentId); const directReports = this.db.getDirectReports(token.agentId, token.companyId); this.json(res, 200, { agent, chainOfCommand, directReports }); }); this.addRoute("GET", "/api/companies/:companyId/issues", (req, res, params, token) => { const companyId = params.companyId!; if (companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`); const assigneeAgentId = url.searchParams.get("assigneeAgentId"); const statusFilter = url.searchParams.get("status"); const statuses = statusFilter ? statusFilter.split(",") : ["todo", "in_progress", "blocked"]; if (assigneeAgentId) { const tasks = this.db.listAgentTasks(assigneeAgentId, companyId, statuses); this.json(res, 200, tasks); } else { const snapshot = this.db.listSnapshot(); const tasks = snapshot.tasks.filter((t) => t.companyId === companyId && statuses.includes(t.status)); this.json(res, 200, tasks); } }); this.addRoute("GET", "/api/issues/:issueId", (_req, res, params, token) => { try { const task = this.db.getTask(params.issueId!); if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; } this.json(res, 200, task); } catch { this.json(res, 404, { error: "Task not found" }); } }); this.addRoute("POST", "/api/issues/:issueId/checkout", (_req, res, params, token, body) => { const parsedBody = checkoutSchema.safeParse(body); if (!parsedBody.success) { this.json(res, 400, { error: "Invalid checkout payload" }); return; } const agentId = parsedBody.data.agentId ?? token.agentId; if (agentId !== token.agentId) { this.json(res, 403, { error: "Agents can only checkout work for themselves" }); return; } let task; try { task = this.db.getTask(params.issueId!); } catch { this.json(res, 404, { error: "Task not found" }); return; } if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; } if (task.assigneeAgentId && !canUpdateTask(this.db, token.companyId, token.agentId, task)) { this.json(res, 403, { error: "Current agent is not allowed to checkout this task" }); return; } const previousStatus = task.status; const success = this.db.checkoutTask(params.issueId!, agentId); if (!success) { this.json(res, 409, { error: "Task cannot be checked out — already assigned or not in valid status" }); return; } this.onDomainChanged(); // Checkout changes status from todo/backlog to in_progress — use actual previous status this.callbacks.onTaskStatusChanged(task.id, task.companyId, previousStatus, "in_progress"); this.json(res, 200, { ok: true }); }); this.addRoute("PATCH", "/api/issues/:issueId", (_req, res, params, token, body) => { const parsedUpdate = taskUpdateSchema.safeParse(body); if (!parsedUpdate.success) { this.json(res, 400, { error: "Invalid task update payload" }); return; } const update = parsedUpdate.data; try { const task = this.db.getTask(params.issueId!); if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; } if (!canUpdateTask(this.db, token.companyId, token.agentId, task)) { this.json(res, 403, { error: "Current agent is not allowed to update this task" }); return; } const newAssignee = update.assigneeAgentId !== undefined ? update.assigneeAgentId : task.assigneeAgentId; if (!canAssignTask(this.db, token.companyId, token.agentId, newAssignee ?? null)) { this.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" }); return; } const input: Record = { id: task.id, companyId: task.companyId, title: update.title ?? task.title, description: update.description ?? task.description, status: update.status ?? task.status, priority: update.priority ?? task.priority, projectId: task.projectId, goalId: task.goalId, parentId: task.parentId, assigneeAgentId: newAssignee, workspaceId: task.workspaceId, }; this.db.saveTask(input as Parameters[0]); if (update.comment) { const agent = this.db.getAgent(token.agentId); this.db.addComment({ companyId: task.companyId, taskId: task.id, authorAgentId: token.agentId, authorName: agent.name, body: update.comment, }); } this.db.addActivity({ companyId: task.companyId, actor: token.agentId, action: `task.${update.status ? "status_changed" : "updated"}`, entityType: "task", entityId: task.id, detail: update.comment ?? `Status → ${update.status ?? task.status}`, }); this.onDomainChanged(); // Fire automation rules for status changes const previousStatus = task.status; const newStatus = update.status ?? task.status; if (update.status && previousStatus !== newStatus) { this.callbacks.onTaskStatusChanged(task.id, task.companyId, previousStatus, newStatus); } if (update.status && ["done", "in_review"].includes(update.status)) { if (task.parentId) { try { const parentTask = this.db.getTask(task.parentId); if (parentTask.assigneeAgentId && parentTask.assigneeAgentId !== token.agentId) { this.onWakeAgent(parentTask.assigneeAgentId, task.companyId, "subtask_completed"); } } catch { /* parent task may have been deleted */ } } else { const chain = this.db.getChainOfCommand(token.agentId); if (chain.length > 0) { this.onWakeAgent(chain[0].id, task.companyId, "subtask_completed"); } } } if (update.status === "blocked") { const chain = this.db.getChainOfCommand(token.agentId); if (chain.length > 0) { this.onWakeAgent(chain[0].id, task.companyId, "report_blocked"); } } if (update.status && ["in_progress", "todo"].includes(update.status) && task.assigneeAgentId && task.assigneeAgentId !== token.agentId) { this.onWakeAgent(task.assigneeAgentId, task.companyId, "assignment"); } if (update.comment) { // Fire comment automation rules this.callbacks.onCommentPosted(task.id, task.companyId, token.agentId); const wokenForComment = new Set(); if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) { this.onWakeAgent(task.assigneeAgentId, task.companyId, "comment"); wokenForComment.add(task.assigneeAgentId); } const chain = this.db.getChainOfCommand(token.agentId); if (chain.length > 0 && chain[0].id !== token.agentId && !wokenForComment.has(chain[0].id)) { this.onWakeAgent(chain[0].id, task.companyId, "comment"); } } // Wake new assignee when task is reassigned if (newAssignee && newAssignee !== task.assigneeAgentId && newAssignee !== token.agentId) { this.onWakeAgent(newAssignee, task.companyId, "assignment"); } this.json(res, 200, { ok: true }); } catch (error) { if (error instanceof Error && error.message.includes("require an assignee")) { this.json(res, 422, { error: error.message }); return; } this.json(res, 404, { error: "Task not found" }); } }); this.addRoute("POST", "/api/companies/:companyId/issues", (_req, res, params, token, body) => { const companyId = params.companyId!; if (companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const parsed = taskInputSchema.safeParse({ companyId, ...(typeof body === "object" && body ? body : {}), }); if (!parsed.success) { this.json(res, 400, { error: "Invalid task payload" }); return; } const input = parsed.data; if (this.companyRefError(res, companyId, [ { id: input.assigneeAgentId, table: "agents", label: "assignee" }, { id: input.parentId, table: "tasks", label: "parent task" }, { id: input.projectId, table: "projects", label: "project" }, { id: input.goalId, table: "goals", label: "goal" }, { id: input.workspaceId, table: "workspaces", label: "workspace" }, ])) { return; } if (!canAssignTask(this.db, companyId, token.agentId, input.assigneeAgentId ?? null)) { this.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" }); return; } let id: string; try { id = this.db.saveTask({ companyId, title: input.title, description: input.description ?? "", assigneeAgentId: input.assigneeAgentId ?? null, parentId: input.parentId ?? null, projectId: input.projectId ?? null, goalId: input.goalId ?? null, priority: input.priority, status: input.status, workspaceId: input.workspaceId ?? null, }); } catch (error) { this.json(res, 422, { error: error instanceof Error ? error.message : "Task could not be saved" }); return; } this.db.addActivity({ companyId, actor: token.agentId, action: "task.created", entityType: "task", entityId: id, detail: input.title, }); this.onDomainChanged(); // Fire the full task creation event chain (automation rules, auto-assign, etc.) this.callbacks.onTaskCreated(id, companyId); // Also wake the assignee directly if specified if (input.assigneeAgentId && input.assigneeAgentId !== token.agentId) { this.onWakeAgent(input.assigneeAgentId, companyId, "assignment"); } // If no assignee specified, auto-assign if (!input.assigneeAgentId) { const assignedId = this.callbacks.onAutoAssignTask(id, companyId); if (assignedId && assignedId !== token.agentId) { this.onWakeAgent(assignedId, companyId, "assignment"); } } this.json(res, 201, { id }); }); this.addRoute("POST", "/api/companies/:companyId/projects", (_req, res, params, token, body) => { const companyId = params.companyId!; if (companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const parsed = projectInputSchema.safeParse({ companyId, ...(typeof body === "object" && body ? body : {}), }); if (!parsed.success) { this.json(res, 400, { error: "Invalid project payload" }); return; } const input = parsed.data; if (this.companyRefError(res, companyId, [ { id: input.goalId, table: "goals", label: "goal" }, { id: input.leadAgentId, table: "agents", label: "lead agent" }, ])) { return; } if (!canManageProject(this.db, companyId, token.agentId, input.leadAgentId ?? null)) { this.json(res, 403, { error: "Current agent is not allowed to create or assign this project" }); return; } const id = this.db.saveProject({ companyId, name: input.name, description: input.description ?? "", goalId: input.goalId ?? null, leadAgentId: input.leadAgentId ?? null, status: (input.status as "planned") ?? "planned", targetDate: input.targetDate ?? null, }); this.db.addActivity({ companyId, actor: token.agentId, action: "project.created", entityType: "project", entityId: id, detail: input.name, }); this.onDomainChanged(); if (input.leadAgentId && input.leadAgentId !== token.agentId) { this.onWakeAgent(input.leadAgentId, companyId, "assignment"); } this.json(res, 201, { id }); }); this.addRoute("PATCH", "/api/projects/:projectId", (_req, res, params, token, body) => { const projectId = params.projectId!; const updateSchema = z.object({ name: z.string().min(2).max(120).optional(), description: z.string().max(2000).optional(), status: z.enum(["planned", "active", "completed", "cancelled"]).optional(), leadAgentId: z.string().uuid().optional(), goalId: z.string().uuid().nullable().optional(), targetDate: z.string().nullable().optional(), }); const parsed = updateSchema.safeParse(body); if (!parsed.success) { this.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; } const snapshot = this.db.listSnapshot(); const existing = snapshot.projects.find(p => p.id === projectId && p.companyId === token.companyId); if (!existing) { this.json(res, 404, { error: "Project not found" }); return; } const nextLeadAgentId = parsed.data.leadAgentId ?? existing.leadAgentId ?? null; if (!canManageProject(this.db, token.companyId, token.agentId, nextLeadAgentId)) { this.json(res, 403, { error: "Current agent is not allowed to update this project" }); return; } try { this.db.saveProject({ id: projectId, companyId: token.companyId, name: parsed.data.name ?? existing.name, description: parsed.data.description ?? existing.description, status: parsed.data.status ?? existing.status, leadAgentId: nextLeadAgentId, goalId: parsed.data.goalId !== undefined ? parsed.data.goalId : (existing.goalId ?? null), targetDate: parsed.data.targetDate !== undefined ? parsed.data.targetDate : (existing.targetDate ?? null), }); this.onDomainChanged(); this.json(res, 200, { ok: true }); } catch { this.json(res, 500, { error: "Failed to update project" }); } }); this.addRoute("POST", "/api/companies/:companyId/goals", (_req, res, params, token, body) => { const companyId = params.companyId!; if (companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const parsed = goalInputSchema.safeParse({ companyId, ...(typeof body === "object" && body ? body : {}), }); if (!parsed.success) { this.json(res, 400, { error: "Invalid goal payload" }); return; } const input = parsed.data; if (this.companyRefError(res, companyId, [ { id: input.parentId, table: "goals", label: "parent goal" }, { id: input.ownerAgentId, table: "agents", label: "owner agent" }, ])) { return; } if (!canManageGoal(this.db, companyId, token.agentId, input.ownerAgentId ?? null)) { this.json(res, 403, { error: "Current agent is not allowed to create this goal" }); return; } const id = this.db.saveGoal(input); this.db.addActivity({ companyId, actor: token.agentId, action: "goal.created", entityType: "goal", entityId: id, detail: input.title, }); this.onDomainChanged(); if (input.ownerAgentId && input.ownerAgentId !== token.agentId && input.status === "active") { this.onWakeAgent(input.ownerAgentId, companyId, "goal_activated"); } this.json(res, 201, { id }); }); this.addRoute("PATCH", "/api/goals/:goalId", (_req, res, params, token, body) => { const goalId = params.goalId!; const updateSchema = z.object({ title: z.string().min(2).max(120).optional(), description: z.string().max(2000).optional(), status: z.enum(["planned", "active", "achieved", "cancelled"]).optional(), ownerAgentId: z.string().uuid().optional(), }); const parsed = updateSchema.safeParse(body); if (!parsed.success) { this.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; } const snapshot = this.db.listSnapshot(); const existing = snapshot.goals.find(g => g.id === goalId && g.companyId === token.companyId); if (!existing) { this.json(res, 404, { error: "Goal not found" }); return; } const nextOwnerAgentId = parsed.data.ownerAgentId ?? existing.ownerAgentId ?? null; if (!canManageGoal(this.db, token.companyId, token.agentId, nextOwnerAgentId)) { this.json(res, 403, { error: "Current agent is not allowed to update this goal" }); return; } try { this.db.saveGoal({ id: goalId, companyId: token.companyId, title: parsed.data.title ?? existing.title, description: parsed.data.description ?? existing.description, status: parsed.data.status ?? existing.status, ownerAgentId: nextOwnerAgentId, }); this.onDomainChanged(); this.json(res, 200, { ok: true }); } catch { this.json(res, 500, { error: "Failed to update goal" }); } }); this.addRoute("POST", "/api/companies/:companyId/agents", (_req, res, params, token, body) => { const companyId = params.companyId!; if (companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } if (!canManageOrganization(this.db, companyId, token.agentId)) { this.json(res, 403, { error: "Current agent is not allowed to request hires" }); return; } const parsed = hireRequestSchema.safeParse({ companyId, requestedByAgentId: token.agentId, ...(typeof body === "object" && body ? body : {}), reportsTo: typeof body === "object" && body && "reportsTo" in body ? (body as { reportsTo?: string | null }).reportsTo : token.agentId, }); if (!parsed.success) { this.json(res, 400, { error: "Invalid hire payload" }); return; } const input = parsed.data; if (this.companyRefError(res, companyId, [ { id: input.requestedByAgentId, table: "agents", label: "requesting agent" }, { id: input.reportsTo, table: "agents", label: "manager" }, { id: input.workspaceId, table: "workspaces", label: "workspace" }, ])) { return; } const managerLabel = input.reportsTo ? (() => { try { return this.db.getAgent(input.reportsTo).name; } catch { return input.reportsTo; } })() : "the company board"; let result: { agentId: string; approvalId: string }; try { result = this.db.createHireRequest({ companyId, requestedByAgentId: input.requestedByAgentId ?? token.agentId, name: input.name, role: input.role, title: input.title, department: input.department ?? null, reportsTo: input.reportsTo ?? token.agentId, connectorId: input.connectorId, workspaceId: input.workspaceId ?? null, model: input.model ?? null, metadataJson: input.metadataJson ?? "{}", capabilities: input.capabilities, budgetMonthlyUsd: input.budgetMonthlyUsd, payloadSummary: `Hire ${input.name} as ${input.role}`, impactSummary: input.capabilities || `${input.name} will report to ${managerLabel}.`, payloadJson: JSON.stringify({ requestedByAgentId: input.requestedByAgentId ?? token.agentId, requestedConfigurationSnapshot: { connectorId: input.connectorId, workspaceId: input.workspaceId ?? null, model: input.model ?? null, metadataJson: input.metadataJson ?? "{}", capabilities: input.capabilities, budgetMonthlyUsd: input.budgetMonthlyUsd, reportsTo: input.reportsTo ?? token.agentId, department: input.department ?? null, title: input.title, }, }), }); } catch (error) { this.json(res, 422, { error: error instanceof Error ? error.message : "Hire could not be created" }); return; } this.db.addActivity({ companyId, actor: token.agentId, action: "agent.hire_requested", entityType: "agent", entityId: result.agentId, detail: input.name, }); let autoApproved = false; if (shouldAutoApproveApproval(this.db, companyId, "hire_agent")) { resolveApprovalDecision({ db: this.db, handleApprovalResolved: this.callbacks.onApprovalResolved, companyId, approvalId: result.approvalId, state: "approved", decisionNote: "Auto-approved by company policy", detail: `Auto-approved hire: ${input.name}`, }); autoApproved = true; log.info(`[auto-approve] Auto-approved hire of ${input.name} via agent API`); } this.onDomainChanged(); this.json(res, 201, { ...result, autoApproved }); }); this.addRoute("POST", "/api/issues/:issueId/comments", (_req, res, params, token, body) => { const input = body as { body?: string } | null; if (!input?.body || typeof input.body !== "string") { this.json(res, 400, { error: "body is required" }); return; } if (input.body.length > 4000) { this.json(res, 400, { error: "Comment body must not exceed 4000 characters" }); return; } try { const task = this.db.getTask(params.issueId!); if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; } const agent = this.db.getAgent(token.agentId); const comment = this.db.addComment({ companyId: task.companyId, taskId: task.id, authorAgentId: token.agentId, authorName: agent.name, body: input.body, }); this.onDomainChanged(); // Fire comment automation rules this.callbacks.onCommentPosted(task.id, task.companyId, token.agentId); const wokenForComment = new Set(); if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) { this.onWakeAgent(task.assigneeAgentId, task.companyId, "comment"); wokenForComment.add(task.assigneeAgentId); } const chain = this.db.getChainOfCommand(token.agentId); if (chain.length > 0 && chain[0].id !== token.agentId && !wokenForComment.has(chain[0].id)) { this.onWakeAgent(chain[0].id, task.companyId, "comment"); } this.json(res, 201, comment); } catch { this.json(res, 404, { error: "Task not found" }); } }); this.addRoute("GET", "/api/issues/:issueId/comments", (_req, res, params, token) => { try { const task = this.db.getTask(params.issueId!); if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; } } catch { this.json(res, 404, { error: "Task not found" }); return; } const comments = this.db.listComments(params.issueId!); this.json(res, 200, comments); }); this.addRoute("POST", "/api/companies/:companyId/approvals", (_req, res, params, token, body) => { const companyId = params.companyId!; if (companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const parsed = approvalInputSchema.safeParse({ companyId, ...(typeof body === "object" && body ? body : {}), requestedByAgentId: token.agentId, }); if (!parsed.success) { this.json(res, 400, { error: "Invalid approval payload" }); return; } const input = parsed.data; if (this.companyRefError(res, companyId, [ { id: input.relatedTaskId, table: "tasks", label: "related task" }, { id: token.agentId, table: "agents", label: "requesting agent" }, { id: input.relatedAgentId, table: "agents", label: "related agent" }, ])) { return; } let id: string; try { id = this.db.requestApproval({ companyId, type: input.type, relatedTaskId: input.relatedTaskId ?? null, requestedByAgentId: token.agentId, relatedAgentId: input.relatedAgentId ?? null, payloadSummary: input.payloadSummary, impactSummary: input.impactSummary, payloadJson: input.payloadJson ?? null, }); } catch (error) { this.json(res, 422, { error: error instanceof Error ? error.message : "Approval could not be created" }); return; } this.db.addActivity({ companyId, actor: token.agentId, action: "approval.created", entityType: "approval", entityId: id, detail: input.payloadSummary, }); if (shouldAutoApproveApproval(this.db, companyId, input.type)) { resolveApprovalDecision({ db: this.db, handleApprovalResolved: this.callbacks.onApprovalResolved, companyId, approvalId: id, state: "approved", decisionNote: "Auto-approved by company policy", detail: "Auto-approved by company policy", }); log.info(`[auto-approve] Auto-approved approval ${id}: ${input.payloadSummary}`); } this.onDomainChanged(); this.json(res, 201, { id }); }); this.addRoute("GET", "/api/companies/:companyId/approvals", (req, res, params, token) => { const companyId = params.companyId!; if (companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`); const parsedQuery = approvalListQuerySchema.safeParse({ requestedByAgentId: url.searchParams.get("requestedByAgentId") ?? undefined, relatedTaskId: url.searchParams.get("relatedTaskId") ?? undefined, status: url.searchParams.get("status") ?? undefined, }); if (!parsedQuery.success) { this.json(res, 400, { error: "Invalid approval query" }); return; } const statuses = parsedQuery.data.status?.split(",").filter(Boolean) ?? null; const snapshot = this.db.listSnapshot(); const approvals = snapshot.approvals.filter((approval) => { if (approval.companyId !== companyId) return false; if (parsedQuery.data.requestedByAgentId && approval.requestedByAgentId !== parsedQuery.data.requestedByAgentId) return false; if (parsedQuery.data.relatedTaskId && approval.relatedTaskId !== parsedQuery.data.relatedTaskId) return false; if (statuses && !statuses.includes(approval.state)) return false; return true; }); this.json(res, 200, approvals); }); this.addRoute("GET", "/api/approvals/:approvalId", (_req, res, params, token) => { const snapshot = this.db.listSnapshot(); const approval = snapshot.approvals.find((a) => a.id === params.approvalId && a.companyId === token.companyId); if (!approval) { this.json(res, 404, { error: "Approval not found" }); return; } this.json(res, 200, approval); }); this.addRoute("PATCH", "/api/approvals/:approvalId", (_req, res) => { this.json(res, 403, { error: "Approvals must be decided from the desktop board, not from an agent run token" }); }); this.addRoute("GET", "/api/companies/:companyId/agents", (_req, res, params, token) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const snapshot = this.db.listSnapshot(); const agents = snapshot.agents.filter((a) => a.companyId === params.companyId); this.json(res, 200, agents); }); this.addRoute("GET", "/api/companies/:companyId/goals", (_req, res, params, token) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const snapshot = this.db.listSnapshot(); const goals = snapshot.goals.filter((g) => g.companyId === params.companyId); this.json(res, 200, goals); }); this.addRoute("GET", "/api/companies/:companyId/projects", (_req, res, params, token) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const snapshot = this.db.listSnapshot(); const projects = snapshot.projects.filter((p) => p.companyId === params.companyId); this.json(res, 200, projects); }); this.addRoute("GET", "/api/agents/me/standup", (_req, res, _params, token) => { const snapshot = this.db.listSnapshot(); const agent = snapshot.agents.find((a) => a.id === token.agentId); if (!agent) { this.json(res, 404, { error: "Agent not found" }); return; } const myTasks = snapshot.tasks.filter((t) => t.assigneeAgentId === token.agentId && t.companyId === token.companyId); const directReports = snapshot.agents.filter((a) => a.reportsTo === token.agentId && a.companyId === token.companyId); const reportIds = new Set(directReports.map((a) => a.id)); const reportTasks = snapshot.tasks.filter((t) => t.assigneeAgentId && reportIds.has(t.assigneeAgentId) && t.companyId === token.companyId); const tasksAwaitingMyReview = reportTasks.filter((t) => t.status === "in_review"); const blockedReportTasks = reportTasks.filter((t) => t.status === "blocked"); const myBlockedTasks = myTasks.filter((t) => t.status === "blocked"); const myInProgressTasks = myTasks.filter((t) => t.status === "in_progress"); const myTodoTasks = myTasks.filter((t) => t.status === "todo"); const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; const recentComments = snapshot.comments .filter((c) => { const isMyTask = myTasks.some((t) => t.id === c.taskId); const isReportTask = reportTasks.some((t) => t.id === c.taskId); return (isMyTask || isReportTask) && new Date(c.createdAt).getTime() > twoHoursAgo; }) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, 20); const myPendingApprovals = snapshot.approvals.filter( (a) => a.requestedByAgentId === token.agentId && a.state === "pending" && a.companyId === token.companyId, ); const myResolvedApprovals = snapshot.approvals.filter( (a) => a.requestedByAgentId === token.agentId && a.state !== "pending" && a.companyId === token.companyId, ).slice(0, 5); this.json(res, 200, { agent: { id: agent.id, name: agent.name, role: agent.role, status: agent.status }, summary: { tasksAwaitingReview: tasksAwaitingMyReview.length, blockedReportTasks: blockedReportTasks.length, myBlockedTasks: myBlockedTasks.length, myInProgress: myInProgressTasks.length, myTodo: myTodoTasks.length, pendingApprovals: myPendingApprovals.length, directReports: directReports.length, }, tasksAwaitingReview: tasksAwaitingMyReview.map((t) => ({ id: t.id, title: t.title, assignee: snapshot.agents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown", priority: t.priority, })), blockedTasks: [...blockedReportTasks, ...myBlockedTasks].map((t) => ({ id: t.id, title: t.title, assignee: snapshot.agents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown", priority: t.priority, })), recentComments: recentComments.map((c) => ({ taskId: c.taskId, taskTitle: snapshot.tasks.find((t) => t.id === c.taskId)?.title ?? "", author: c.authorName, body: c.body.slice(0, 200), createdAt: c.createdAt, })), pendingApprovals: myPendingApprovals.map((a) => ({ id: a.id, type: a.type, summary: a.payloadSummary, state: a.state, })), resolvedApprovals: myResolvedApprovals.map((a) => ({ id: a.id, type: a.type, summary: a.payloadSummary, state: a.state, decisionNote: a.decisionNote, })), reportStatuses: directReports.map((r) => { const rTasks = snapshot.tasks.filter((t) => t.assigneeAgentId === r.id && t.companyId === token.companyId); return { id: r.id, name: r.name, role: r.role, status: r.status, tasksInProgress: rTasks.filter((t) => t.status === "in_progress").length, tasksInReview: rTasks.filter((t) => t.status === "in_review").length, tasksBlocked: rTasks.filter((t) => t.status === "blocked").length, tasksDone: rTasks.filter((t) => t.status === "done").length, }; }), socialAccounts: snapshot.socialAccounts .filter((a) => a.companyId === token.companyId) .map((a) => ({ id: a.id, platform: a.platform, accountName: a.accountName, displayName: a.displayName, status: a.status, requireApproval: a.requireApproval, })), }); }); this.addRoute("POST", "/api/companies/:companyId/browser/action", async (_req, res, params, token, body) => { const companyId = params.companyId!; if (companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const actionSchema = z.object({ socialAccountId: z.string().uuid(), actionType: z.enum(["post", "reply", "like", "follow", "browse_feed", "screenshot", "navigate", "search"]), payload: z.record(z.string(), z.unknown()).default({}), }); const parsed = actionSchema.safeParse(body); if (!parsed.success) { this.json(res, 400, { error: "Invalid browser action payload", details: parsed.error.issues }); return; } const input = parsed.data; const snapshot = this.db.listSnapshot(); const account = snapshot.socialAccounts.find(a => a.id === input.socialAccountId && a.companyId === companyId); if (!account) { this.json(res, 404, { error: "Social account not found" }); return; } const postActions = ["post", "reply", "dm"]; if (account.requireApproval && postActions.includes(input.actionType)) { const approvalId = this.db.requestApproval({ companyId, type: "dangerous_command", relatedTaskId: null, requestedByAgentId: token.agentId, payloadSummary: `${input.actionType} on ${account.platform}/@${account.accountName}: ${String(input.payload.content || "").slice(0, 100)}`, impactSummary: `Will ${input.actionType} on ${account.displayName || account.accountName}'s ${account.platform} account. Content will be publicly visible.`, }); const actionId = this.db.saveBrowserAction({ companyId, socialAccountId: input.socialAccountId, agentId: token.agentId, actionType: input.actionType, payloadJson: JSON.stringify(input.payload), status: "approval_required", approvalId, }); this.db.addActivity({ companyId, actor: token.agentId, action: "browser.action_pending_approval", entityType: "browser_action", entityId: actionId, detail: `${input.actionType} on ${account.platform} requires approval`, }); this.onDomainChanged(); this.json(res, 202, { id: actionId, status: "approval_required", approvalId, message: "Action requires Board approval before execution" }); return; } const actionId = this.db.saveBrowserAction({ companyId, socialAccountId: input.socialAccountId, agentId: token.agentId, actionType: input.actionType, payloadJson: JSON.stringify(input.payload), status: "queued", }); this.db.addActivity({ companyId, actor: token.agentId, action: "browser.action_queued", entityType: "browser_action", entityId: actionId, detail: `${input.actionType} on ${account.platform}/@${account.accountName}`, }); this.onDomainChanged(); if (this.onBrowserAction) { this.onBrowserAction(actionId, companyId, input.socialAccountId, input.actionType, input.payload as Record); } this.json(res, 201, { id: actionId, status: "queued", message: "Browser action queued for execution" }); }); this.addRoute("GET", "/api/companies/:companyId/social-accounts", (_req, res, params, token) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const accounts = this.db.listSocialAccounts(params.companyId!); const sanitized = accounts.map(a => ({ id: a.id, platform: a.platform, accountName: a.accountName, displayName: a.displayName, profileUrl: a.profileUrl, status: a.status, requireApproval: a.requireApproval, })); this.json(res, 200, sanitized); }); this.addRoute("GET", "/api/companies/:companyId/browser/actions", (req, res, params, token) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`); const socialAccountId = url.searchParams.get("socialAccountId") ?? undefined; const status = url.searchParams.get("status") ?? undefined; const limit = Number(url.searchParams.get("limit") || 50); const actions = this.db.listBrowserActions(params.companyId!, { socialAccountId, status, limit }); this.json(res, 200, actions); }); this.addRoute("POST", "/api/companies/:companyId/documents", (_req, res, params, token, body) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const data = body as Record; const reviewerAgentId = data.reviewerAgentId ? String(data.reviewerAgentId) : null; const projectId = data.projectId ? String(data.projectId) : null; const goalId = data.goalId ? String(data.goalId) : null; if (reviewerAgentId && !this.db.belongsToCompany("agents", reviewerAgentId, token.companyId)) { this.json(res, 400, { error: "Reviewer agent does not belong to the current company" }); return; } if (projectId && !this.db.belongsToCompany("projects", projectId, token.companyId)) { this.json(res, 400, { error: "Project does not belong to the current company" }); return; } if (goalId && !this.db.belongsToCompany("goals", goalId, token.companyId)) { this.json(res, 400, { error: "Goal does not belong to the current company" }); return; } const id = this.db.saveDocument({ companyId: token.companyId, type: String(data.type ?? "knowledge_article"), title: String(data.title ?? "Untitled"), content: String(data.content ?? ""), authorAgentId: token.agentId, reviewerAgentId, projectId, goalId, status: String(data.status ?? "draft"), tagsJson: data.tagsJson ? String(data.tagsJson) : "[]", }); this.onDomainChanged(); // Fire document creation automation rules this.callbacks.onDocumentCreated(token.companyId, token.agentId, projectId); // Wake document reviewer if specified if (reviewerAgentId) { this.onWakeAgent(reviewerAgentId, token.companyId, "assignment"); } this.json(res, 201, { id }); }); this.addRoute("GET", "/api/companies/:companyId/documents", (req, res, params, token) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const snapshot = this.db.listSnapshot(); const docs = snapshot.documents.filter(d => d.companyId === token.companyId); this.json(res, 200, docs); }); this.addRoute("POST", "/api/companies/:companyId/meetings", (_req, res, params, token, body) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const data = body as Record; const participantAgentIds = data.participantAgentIds ? String(data.participantAgentIds) : "[]"; try { const parsedParticipants = JSON.parse(participantAgentIds) as string[]; for (const agentId of parsedParticipants) { if (typeof agentId !== "string" || !this.db.belongsToCompany("agents", agentId, token.companyId)) { this.json(res, 400, { error: "Meeting participants must belong to the current company" }); return; } } } catch { this.json(res, 400, { error: "participantAgentIds must be a JSON array of agent ids" }); return; } const id = this.db.saveMeeting({ companyId: token.companyId, type: String(data.type ?? "department_sync"), title: String(data.title ?? "Meeting"), organizerAgentId: token.agentId, participantAgentIds, scheduledAt: String(data.scheduledAt ?? new Date().toISOString()), durationMinutes: Number(data.durationMinutes ?? 30), agendaJson: data.agendaJson ? String(data.agendaJson) : "[]", status: "scheduled", }); this.onDomainChanged(); this.json(res, 201, { id }); }); this.addRoute("GET", "/api/companies/:companyId/meetings", (_req, res, params, token) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const snapshot = this.db.listSnapshot(); const meetings = snapshot.meetings.filter(m => m.companyId === token.companyId); this.json(res, 200, meetings); }); this.addRoute("PATCH", "/api/meetings/:meetingId", (_req, res, params, token, body) => { const snapshot = this.db.listSnapshot(); const existing = snapshot.meetings.find(m => m.id === params.meetingId && m.companyId === token.companyId); if (!existing) { this.json(res, 404, { error: "Meeting not found" }); return; } const data = body as Record; let actionItems: Array<{ title?: string; assigneeAgentId?: string; description?: string }> = []; if (data.actionItemsJson) { try { actionItems = JSON.parse(String(data.actionItemsJson)) as Array<{ title?: string; assigneeAgentId?: string; description?: string }>; } catch { this.json(res, 400, { error: "actionItemsJson must be valid JSON" }); return; } for (const item of actionItems) { if (item.assigneeAgentId && !this.db.belongsToCompany("agents", item.assigneeAgentId, token.companyId)) { this.json(res, 400, { error: "Meeting action item assignees must belong to the current company" }); return; } } } this.db.saveMeeting({ id: params.meetingId, companyId: token.companyId, type: data.type ? String(data.type) : "department_sync", title: data.title ? String(data.title) : "Meeting", notesJson: data.notesJson ? String(data.notesJson) : undefined, decisionsJson: data.decisionsJson ? String(data.decisionsJson) : undefined, actionItemsJson: data.actionItemsJson ? String(data.actionItemsJson) : undefined, status: data.status ? String(data.status) : undefined, scheduledAt: data.scheduledAt ? String(data.scheduledAt) : new Date().toISOString(), }); this.onDomainChanged(); // When action items are added, create tasks for them automatically if (actionItems.length > 0) { for (const item of actionItems) { if (item.title && typeof item.title === "string") { const taskId = this.db.saveTask({ companyId: token.companyId, title: item.title, description: item.description ?? `Action item from meeting: ${data.title ?? existing.title}`, assigneeAgentId: item.assigneeAgentId ?? null, status: item.assigneeAgentId ? "todo" : "backlog", priority: "medium", }); if (item.assigneeAgentId) { this.onWakeAgent(item.assigneeAgentId, token.companyId, "assignment"); } void taskId; } } } this.json(res, 200, { updated: true }); }); this.addRoute("POST", "/api/companies/:companyId/knowledge", (_req, res, params, token, body) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const data = body as Record; const id = this.db.saveKnowledgeEntry({ companyId: token.companyId, category: String(data.category ?? "lesson_learned"), topic: String(data.topic ?? ""), content: String(data.content ?? ""), authorAgentId: token.agentId, importance: String(data.importance ?? "medium"), tagsJson: data.tagsJson ? String(data.tagsJson) : "[]", referencedEntityType: data.referencedEntityType ? String(data.referencedEntityType) : null, referencedEntityId: data.referencedEntityId ? String(data.referencedEntityId) : null, }); this.onDomainChanged(); this.json(res, 201, { id }); }); this.addRoute("GET", "/api/companies/:companyId/knowledge", (_req, res, params, token) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const snapshot = this.db.listSnapshot(); const entries = snapshot.knowledgeBase.filter(k => k.companyId === token.companyId); this.json(res, 200, entries); }); this.addRoute("POST", "/api/companies/:companyId/messages", (_req, res, params, token, body) => { const companyId = params.companyId!; if (companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const parsed = agentMessageInputSchema.safeParse({ ...(body as Record), companyId, fromAgentId: token.agentId, }); if (!parsed.success) { this.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; } const dispatchResult = dispatchAgentMessage( { db: this.db, emitEvent: this.emitEvent, publishDomainChanged: this.onDomainChanged, wakeAgentIfPossible: (agentId, companyId, trigger) => { if (!agentId) return null; return this.onWakeAgent(agentId, companyId, trigger); }, }, parsed.data, "message", ); if (!dispatchResult.ok) { this.json(res, 400, { error: dispatchResult.error.message, code: dispatchResult.error.code }); return; } this.json(res, 201, { id: dispatchResult.data.id }); }); this.addRoute("GET", "/api/companies/:companyId/messages", (req, res, params, token) => { const companyId = params.companyId!; if (companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`); const channelParam = url.searchParams.get("channel"); const channelTargetId = url.searchParams.get("channelTargetId"); const unreadOnly = url.searchParams.get("unreadOnly") === "true"; const limitStr = url.searchParams.get("limit"); const limit = limitStr ? Math.min(Math.max(parseInt(limitStr, 10) || 50, 1), 200) : 50; const allowedChannels = new Set(["direct", "department", "company", "project", "incident"]); if (channelParam && !allowedChannels.has(channelParam)) { this.json(res, 400, { error: `Invalid channel filter: ${channelParam}` }); return; } const messages = this.db.listAgentMessages(companyId, { agentId: token.agentId, channel: channelParam ?? undefined, channelTargetId: channelTargetId && channelTargetId.length > 0 ? channelTargetId : undefined, unreadOnly, limit, }); this.json(res, 200, messages); }); this.addRoute("POST", "/api/companies/:companyId/messages/:messageId/read", (_req, res, params, token) => { const companyId = params.companyId!; if (companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const message = this.db.getAgentMessage(params.messageId!, companyId); if (!message) { this.json(res, 404, { error: "Message not found" }); return; } const visibleToAgent = this.db.listAgentMessages(companyId, { agentId: token.agentId, messageId: message.id, limit: 1, }).some((entry) => entry.id === message.id); if (!visibleToAgent) { this.json(res, 403, { error: "This message is not visible to the current agent" }); return; } this.db.markAgentMessageRead(params.messageId!, companyId, token.agentId); this.onDomainChanged(); this.json(res, 200, { ok: true }); }); this.addRoute("GET", "/api/companies/:companyId/sprints", (_req, res, params, token) => { const companyId = params.companyId!; if (companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const snapshot = this.db.listSnapshot(); const sprints = snapshot.sprints.filter(s => s.companyId === companyId); this.json(res, 200, sprints); }); this.addRoute("POST", "/api/companies/:companyId/sprints", (_req, res, params, token, body) => { const companyId = params.companyId!; if (companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const data = body as Record; try { const id = this.db.saveSprint({ companyId, name: String(data.name ?? "Sprint"), goal: String(data.goal ?? ""), startDate: String(data.startDate ?? new Date().toISOString()), endDate: String(data.endDate ?? new Date(Date.now() + 14 * 86400000).toISOString()), status: String(data.status ?? "planning") as "planning" | "active" | "review" | "completed" | "cancelled", retrospectiveNotes: String(data.retrospectiveNotes ?? ""), velocityPoints: Number(data.velocityPoints ?? 0), completedPoints: Number(data.completedPoints ?? 0), }); this.onDomainChanged(); this.json(res, 201, { id }); } catch { this.json(res, 400, { error: "Invalid sprint data" }); } }); this.addRoute("GET", "/api/agents/:agentId/metrics", (_req, res, params, token) => { const snapshot = this.db.listSnapshot(); const agent = snapshot.agents.find((entry) => entry.id === params.agentId && entry.companyId === token.companyId); if (!agent) { this.json(res, 404, { error: "Agent not found" }); return; } const companyMetrics = this.db.getCompanyMetrics(token.companyId, 30); const agentMetric = companyMetrics.agentMetrics.find((metric) => metric.agentId === params.agentId); if (!agentMetric) { this.json(res, 404, { error: "Metrics not found" }); return; } this.json(res, 200, agentMetric); }); this.addRoute("GET", "/api/agents/me/metrics", (_req, res, _params, token) => { const companyMetrics = this.db.getCompanyMetrics(token.companyId, 30); const agentMetric = companyMetrics.agentMetrics.find(m => m.agentId === token.agentId); if (!agentMetric) { this.json(res, 404, { error: "Metrics not found" }); return; } this.json(res, 200, agentMetric); }); this.addRoute("GET", "/api/agents/:agentId", (_req, res, params, token) => { const snapshot = this.db.listSnapshot(); const agent = snapshot.agents.find(a => a.id === params.agentId && a.companyId === token.companyId); if (!agent) { this.json(res, 404, { error: "Agent not found" }); return; } const directReports = snapshot.agents.filter(a => a.reportsTo === agent.id && a.companyId === token.companyId); const manager = agent.reportsTo ? snapshot.agents.find(a => a.id === agent.reportsTo) : null; this.json(res, 200, { ...agent, manager: manager ? { id: manager.id, name: manager.name, role: manager.role } : null, directReports: directReports.map(r => ({ id: r.id, name: r.name, role: r.role, status: r.status })), }); }); this.addRoute("POST", "/api/agents/:agentId/wake", (_req, res, params, token, body) => { const targetAgentId = params.agentId!; const targetAgent = (() => { try { return this.db.getAgent(targetAgentId); } catch { return null; } })(); if (!targetAgent || targetAgent.companyId !== token.companyId) { this.json(res, 404, { error: "Agent not found" }); return; } if (!canControlAgent(this.db, token.companyId, token.agentId, targetAgentId)) { this.json(res, 403, { error: "Current agent is not allowed to wake this agent" }); return; } const wakeSchema = z.object({ reason: z.string().min(1).max(200).default("peer_request"), }); const parsed = wakeSchema.safeParse(body); const reason = parsed.success ? parsed.data.reason : "peer_request"; const alreadyActive = this.db.hasActiveRun({ companyId: token.companyId, agentId: targetAgentId }); const runId = this.onWakeAgent(targetAgentId, token.companyId, reason); if (runId) { this.json(res, 200, { ok: true, runId, message: `Wake started for agent ${targetAgentId}` }); return; } if (alreadyActive) { this.json(res, 202, { ok: true, queued: true, message: `Agent ${targetAgentId} is already running. Wake will be retried after the active run completes.`, }); return; } this.json(res, 409, { ok: false, error: "Agent could not be woken right now" }); }); this.addRoute("PATCH", "/api/agents/:agentId/heartbeat", (_req, res, params, token) => { const targetAgentId = params.agentId!; const snapshot = this.db.listSnapshot(); const targetAgent = snapshot.agents.find( (a) => a.id === targetAgentId && a.companyId === token.companyId, ); if (!targetAgent) { this.json(res, 404, { error: "Agent not found or not in the same company" }); return; } if (!canControlAgent(this.db, token.companyId, token.agentId, targetAgentId)) { this.json(res, 403, { error: "Current agent is not allowed to trigger heartbeat for this agent" }); return; } const alreadyActive = this.db.hasActiveRun({ companyId: token.companyId, agentId: targetAgentId }); const runId = this.onWakeAgent(targetAgentId, token.companyId, "peer_request"); if (runId) { this.json(res, 200, { ok: true, runId, message: `Heartbeat triggered for agent ${targetAgentId}` }); return; } if (alreadyActive) { this.json(res, 202, { ok: true, queued: true, message: `Agent ${targetAgentId} is already running. Heartbeat will retry after the current run.`, }); return; } this.json(res, 409, { ok: false, error: "Heartbeat could not be started for this agent" }); }); this.addRoute("GET", "/api/companies/:companyId/dashboard", (_req, res, params, token) => { const companyId = params.companyId!; if (companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; } const snapshot = this.db.listSnapshot(); const agents = snapshot.agents.filter((a) => a.companyId === companyId); const tasks = snapshot.tasks.filter((t) => t.companyId === companyId); const recentActivity = snapshot.activity .filter((a) => a.companyId === companyId) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, 20); const tasksByStatus: Record = {}; for (const t of tasks) { tasksByStatus[t.status] = (tasksByStatus[t.status] ?? 0) + 1; } this.json(res, 200, { totalAgents: agents.length, activeAgents: agents.filter((a) => a.status === "active" || a.status === "running").length, totalTasks: tasks.length, tasksByStatus, recentActivity: recentActivity.map((a) => ({ id: a.id, actor: a.actor, action: a.action, entityType: a.entityType, entityId: a.entityId, detail: a.detail, createdAt: a.createdAt, })), }); }); } }