diff --git a/eslint.config.mjs b/eslint.config.mjs index c03b81e3..5d418a95 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,7 @@ export default tseslint.config( "out/**", "build/**", "dist/**", + "oldreference/**", "node_modules/**", "test-results/**", "playwright-report/**", diff --git a/package.json b/package.json index 2ddcfbb3..2b89e558 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-company", - "version": "0.3.0", + "version": "0.4.16", "private": true, "type": "module", "description": "Cross-platform Electron desktop control plane for local-first AI agent companies.", diff --git a/scripts/build-desktop.mjs b/scripts/build-desktop.mjs index a33b7c35..bdb2897d 100644 --- a/scripts/build-desktop.mjs +++ b/scripts/build-desktop.mjs @@ -1,8 +1,33 @@ import { spawn } from "node:child_process"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageJsonPath = join(__dirname, "..", "package.json"); +const releaseDir = join(__dirname, "..", "build", "release"); const extraArgs = process.argv.slice(2); const env = { ...process.env }; +function bumpPatchVersion(currentVersion) { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(currentVersion); + if (!match) { + throw new Error(`[build-desktop] Unsupported package version format: ${currentVersion}`); + } + const [, major, minor, patch] = match; + return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`; +} + +async function prepareBuildVersion() { + const raw = await readFile(packageJsonPath, "utf8"); + const pkg = JSON.parse(raw); + const nextVersion = bumpPatchVersion(pkg.version); + pkg.version = nextVersion; + await writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8"); + return nextVersion; +} + if (env.CSC_IDENTITY_AUTO_DISCOVERY === undefined) { env.CSC_IDENTITY_AUTO_DISCOVERY = "false"; } @@ -29,15 +54,17 @@ if (!explicitPublishArg) { } builderArgs.push(...extraArgs); -const child = spawn( - "pnpm", - builderArgs, - { - stdio: "inherit", - env, - shell: process.platform === "win32", - }, -); +await rm(releaseDir, { recursive: true, force: true }); +const buildVersion = env.AGENTCOMPANY_BUILD_VERSION?.trim() || await prepareBuildVersion(); +env.AGENTCOMPANY_BUILD_VERSION = buildVersion; +env.npm_package_version = buildVersion; +console.log(`[build-desktop] Cleaned build output and bumped package version to ${buildVersion}.`); + +const child = spawn("pnpm", builderArgs, { + stdio: "inherit", + env, + shell: process.platform === "win32", +}); child.on("exit", (code, signal) => { if (signal) { diff --git a/src/main/agent-auth.ts b/src/main/agent-auth.ts index a9349256..b6c31e52 100644 --- a/src/main/agent-auth.ts +++ b/src/main/agent-auth.ts @@ -1,13 +1,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; -/** - * Lightweight HMAC-based agent token system. - * Tokens are short-lived and scoped to a specific agent + company + run. - * No external JWT library needed. - */ - const SIGNING_KEY = randomBytes(32); -const TOKEN_TTL_MS = 12 * 60 * 60 * 1000; // 12 hours +const TOKEN_TTL_MS = 48 * 60 * 60 * 1000; // 48 hours interface TokenPayload { agentId: string; diff --git a/src/main/api-server.ts b/src/main/api-server.ts index cc7185ba..df8d3a32 100644 --- a/src/main/api-server.ts +++ b/src/main/api-server.ts @@ -25,6 +25,8 @@ const taskUpdateSchema = z.object({ 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({ @@ -37,28 +39,45 @@ const approvalListQuerySchema = z.object({ status: z.string().optional(), }); -/** - * Lightweight local HTTP API server for agent-to-system interaction. - * Agents call this API during runs to create tasks, request approvals, - * post comments, and query their assignments. - */ +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; +} + 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) => void; - private onBrowserAction: ((actionId: string, socialAccountId: string, actionType: string, payload: Record) => void) | null = null; + private onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null; + private callbacks: ApiServerEventCallbacks; + 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) => void) { + constructor(db: AppDatabase, onDomainChanged: () => void, onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null, callbacks?: Partial) { this.db = db; this.onDomainChanged = onDomainChanged; this.onWakeAgent = onWakeAgent; + this.callbacks = { + onDomainChanged, + onWakeAgent, + onTaskCreated: callbacks?.onTaskCreated ?? (() => {}), + onTaskStatusChanged: callbacks?.onTaskStatusChanged ?? (() => {}), + onCommentPosted: callbacks?.onCommentPosted ?? (() => {}), + onApprovalResolved: callbacks?.onApprovalResolved ?? (() => {}), + onDocumentCreated: callbacks?.onDocumentCreated ?? (() => {}), + onAutoAssignTask: callbacks?.onAutoAssignTask ?? (() => null), + }; this.registerRoutes(); } - setBrowserActionHandler(handler: (actionId: string, socialAccountId: string, actionType: string, payload: Record) => void) { + setBrowserActionHandler(handler: (actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record) => void) { this.onBrowserAction = handler; } @@ -116,12 +135,10 @@ export class AgentApiServer { return; } - // Parse URL const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`); const pathname = url.pathname; const method = (req.method ?? "GET").toUpperCase(); - // Auth const authHeader = req.headers.authorization; if (!authHeader?.startsWith("Bearer ")) { this.json(res, 401, { error: "Missing or invalid Authorization header" }); @@ -133,13 +150,11 @@ export class AgentApiServer { return; } - // Bind token to its run — verify the run exists and belongs to the token's company if (!this.db.belongsToCompany("runs", token.runId, token.companyId)) { this.json(res, 403, { error: "Token references an unknown or mismatched run" }); return; } - // Match route for (const route of this.routes) { if (route.method !== method) continue; const match = pathname.match(route.pattern); @@ -150,13 +165,16 @@ export class AgentApiServer { params[name] = match[i + 1]!; }); - // Parse body for POST/PATCH/PUT let body: unknown = null; if (["POST", "PATCH", "PUT"].includes(method)) { try { body = await this.readBody(req); - } catch { - this.json(res, 400, { error: "Invalid JSON body" }); + } 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; } } @@ -213,7 +231,6 @@ export class AgentApiServer { } private registerRoutes() { - // GET /api/agents/me — Agent identity this.addRoute("GET", "/api/agents/me", (_req, res, _params, token) => { const agent = this.db.getAgent(token.agentId); const chainOfCommand = this.db.getChainOfCommand(token.agentId); @@ -221,7 +238,6 @@ export class AgentApiServer { this.json(res, 200, { agent, chainOfCommand, directReports }); }); - // GET /api/companies/:companyId/issues — List issues with filters this.addRoute("GET", "/api/companies/:companyId/issues", (req, res, params, token) => { const companyId = params.companyId!; if (companyId !== token.companyId) { @@ -237,14 +253,12 @@ export class AgentApiServer { const tasks = this.db.listAgentTasks(assigneeAgentId, companyId, statuses); this.json(res, 200, tasks); } else { - // Return all company tasks with the given statuses const snapshot = this.db.listSnapshot(); const tasks = snapshot.tasks.filter((t) => t.companyId === companyId && statuses.includes(t.status)); this.json(res, 200, tasks); } }); - // GET /api/issues/:issueId — Get single issue this.addRoute("GET", "/api/issues/:issueId", (_req, res, params, token) => { try { const task = this.db.getTask(params.issueId!); @@ -258,7 +272,6 @@ export class AgentApiServer { } }); - // POST /api/issues/:issueId/checkout — Claim a task this.addRoute("POST", "/api/issues/:issueId/checkout", (_req, res, params, token, body) => { const parsedBody = checkoutSchema.safeParse(body); if (!parsedBody.success) { @@ -287,10 +300,14 @@ export class AgentApiServer { return; } this.onDomainChanged(); + // Checkout changes status from todo/backlog to in_progress + const checkedOutTask = (() => { try { return this.db.getTask(params.issueId!); } catch { return null; } })(); + if (checkedOutTask) { + this.callbacks.onTaskStatusChanged(checkedOutTask.id, checkedOutTask.companyId, "todo", "in_progress"); + } this.json(res, 200, { ok: true }); }); - // PATCH /api/issues/:issueId — Update task this.addRoute("PATCH", "/api/issues/:issueId", (_req, res, params, token, body) => { const parsedUpdate = taskUpdateSchema.safeParse(body); if (!parsedUpdate.success) { @@ -306,23 +323,22 @@ export class AgentApiServer { return; } - // Update fields + const newAssignee = update.assigneeAgentId !== undefined ? update.assigneeAgentId : task.assigneeAgentId; 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: task.priority, + priority: update.priority ?? task.priority, projectId: task.projectId, goalId: task.goalId, parentId: task.parentId, - assigneeAgentId: task.assigneeAgentId, + assigneeAgentId: newAssignee, workspaceId: task.workspaceId, }; this.db.saveTask(input as Parameters[0]); - // Add comment if provided if (update.comment) { const agent = this.db.getAgent(token.agentId); this.db.addComment({ @@ -345,8 +361,13 @@ export class AgentApiServer { this.onDomainChanged(); - // Step 5a: Wake propagation on task status change - // Completion notification → task tree (parentId → parent assignee) + // 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 { @@ -356,7 +377,6 @@ export class AgentApiServer { } } catch { /* parent task may have been deleted */ } } else { - // Root task (no parent) — fall back to org hierarchy to notify manager const chain = this.db.getChainOfCommand(token.agentId); if (chain.length > 0) { this.onWakeAgent(chain[0].id, task.companyId, "subtask_completed"); @@ -364,7 +384,6 @@ export class AgentApiServer { } } - // Blocked escalation → org hierarchy (agent's manager) if (update.status === "blocked") { const chain = this.db.getChainOfCommand(token.agentId); if (chain.length > 0) { @@ -372,16 +391,15 @@ export class AgentApiServer { } } - // Review feedback: when a non-assignee (manager) changes status back to - // in_progress or todo, wake the assignee so they know to address feedback if (update.status && ["in_progress", "todo"].includes(update.status) && task.assigneeAgentId && task.assigneeAgentId !== token.agentId) { this.onWakeAgent(task.assigneeAgentId, task.companyId, "assignment"); } - // Comment with status change → wake assignee + manager for the comment - // Comment-only PATCH (no status change) → also wake assignee + manager 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"); @@ -393,6 +411,11 @@ export class AgentApiServer { } } + // 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")) { @@ -403,7 +426,6 @@ export class AgentApiServer { } }); - // POST /api/companies/:companyId/issues — Create task/subtask this.addRoute("POST", "/api/companies/:companyId/issues", (_req, res, params, token, body) => { const companyId = params.companyId!; if (companyId !== token.companyId) { @@ -459,21 +481,25 @@ export class AgentApiServer { this.onDomainChanged(); - // Auto-cascade: when a task is assigned to an agent, enable their heartbeat and wake them + // 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) { - try { - const assignee = this.db.getAgent(input.assigneeAgentId); - if (!assignee.heartbeatEnabled) { - this.db.setHeartbeat(input.assigneeAgentId, true, assignee.heartbeatIntervalSec || 120); - } - this.onWakeAgent(input.assigneeAgentId, companyId, "assignment"); - } catch { /* ignore if agent not found */ } + 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 }); }); - // POST /api/companies/:companyId/projects — Create project this.addRoute("POST", "/api/companies/:companyId/projects", (_req, res, params, token, body) => { const companyId = params.companyId!; if (companyId !== token.companyId) { @@ -517,20 +543,54 @@ export class AgentApiServer { this.onDomainChanged(); if (input.leadAgentId && input.leadAgentId !== token.agentId) { - try { - const lead = this.db.getAgent(input.leadAgentId); - if (!lead.heartbeatEnabled) { - this.db.setHeartbeat(input.leadAgentId, true, lead.heartbeatIntervalSec || 120); - } - this.onWakeAgent(input.leadAgentId, companyId, "assignment"); - } catch { - // Ignore missing lead agents after validation drift. - } + this.onWakeAgent(input.leadAgentId, companyId, "assignment"); } this.json(res, 201, { id }); }); - // POST /api/companies/:companyId/goals — Create goal + 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; + } + + 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: parsed.data.leadAgentId ?? existing.leadAgentId ?? null, + 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) { @@ -564,20 +624,50 @@ export class AgentApiServer { }); this.onDomainChanged(); if (input.ownerAgentId && input.ownerAgentId !== token.agentId && input.status === "active") { - try { - const owner = this.db.getAgent(input.ownerAgentId); - if (!owner.heartbeatEnabled) { - this.db.setHeartbeat(input.ownerAgentId, true, owner.heartbeatIntervalSec || 120); - } - this.onWakeAgent(input.ownerAgentId, companyId, "goal_activated"); - } catch { - // Ignore missing owner agents after validation drift. - } + this.onWakeAgent(input.ownerAgentId, companyId, "goal_activated"); } this.json(res, 201, { id }); }); - // POST /api/companies/:companyId/agents — Propose a hire + 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; + } + + 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: parsed.data.ownerAgentId ?? existing.ownerAgentId ?? null, + }); + 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) { @@ -613,6 +703,7 @@ export class AgentApiServer { name: input.name, role: input.role, title: input.title, + department: input.department ?? null, status: "pending_approval", reportsTo: input.reportsTo ?? token.agentId, connectorId: input.connectorId, @@ -630,6 +721,7 @@ export class AgentApiServer { const approvalId = this.db.requestApproval({ companyId, requestedByAgentId: token.agentId, + relatedAgentId: agentId, type: "hire_agent", payloadSummary: `Hire ${input.name} as ${input.role}`, impactSummary: input.capabilities || `${input.name} will report to ${input.reportsTo ?? token.agentId}.`, @@ -660,7 +752,6 @@ export class AgentApiServer { this.json(res, 201, { agentId, approvalId }); }); - // POST /api/issues/:issueId/comments — Add comment 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") { @@ -687,14 +778,15 @@ export class AgentApiServer { }); this.onDomainChanged(); - // Step 5b: Wake task assignee (if not the comment author) + // 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); } - // Wake comment author's manager (org hierarchy — for upward reporting chain) 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"); @@ -706,7 +798,6 @@ export class AgentApiServer { } }); - // GET /api/issues/:issueId/comments — List comments this.addRoute("GET", "/api/issues/:issueId/comments", (_req, res, params, token) => { try { const task = this.db.getTask(params.issueId!); @@ -722,7 +813,6 @@ export class AgentApiServer { this.json(res, 200, comments); }); - // POST /api/companies/:companyId/approvals — Request approval this.addRoute("POST", "/api/companies/:companyId/approvals", (_req, res, params, token, body) => { const companyId = params.companyId!; if (companyId !== token.companyId) { @@ -764,11 +854,11 @@ export class AgentApiServer { detail: input.payloadSummary, }); - // Auto-approve hire requests if company has auto_approve_hires enabled const snapshot = this.db.listSnapshot(); const company = snapshot.companies.find((c) => c.id === companyId); if (company?.autoApproveHires && (input.type === "hire_agent" || input.type === "approve_ceo_strategy")) { - this.db.decideApproval({ approvalId: id, state: "approved", decisionNote: "Auto-approved by company policy" }); + this.db.decideApproval({ companyId, approvalId: id, state: "approved", decisionNote: "Auto-approved by company policy" }); + this.callbacks.onApprovalResolved(id, companyId, "approved"); this.db.addActivity({ companyId, actor: "board", @@ -777,16 +867,6 @@ export class AgentApiServer { entityId: id, detail: "Auto-approved by company policy", }); - // Wake the requesting agent so they know the hire was approved - this.onWakeAgent(token.agentId, companyId, "approval_resolved"); - // Wake the newly hired agent - try { - const fullApproval = this.db.getApprovalRecord(id); - const payload = JSON.parse(fullApproval.payloadJson || "{}") as { agentId?: string }; - if (payload.agentId) { - this.onWakeAgent(payload.agentId, companyId, "assignment"); - } - } catch { /* ignore if payload is malformed */ } log.info(`[auto-approve] Auto-approved hire request: ${input.payloadSummary}`); } @@ -794,7 +874,6 @@ export class AgentApiServer { this.json(res, 201, { id }); }); - // GET /api/companies/:companyId/approvals — List approvals for polling this.addRoute("GET", "/api/companies/:companyId/approvals", (req, res, params, token) => { const companyId = params.companyId!; if (companyId !== token.companyId) { @@ -823,7 +902,6 @@ export class AgentApiServer { this.json(res, 200, approvals); }); - // GET /api/approvals/:approvalId — Get approval 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); @@ -834,7 +912,48 @@ export class AgentApiServer { this.json(res, 200, approval); }); - // GET /api/companies/:companyId/agents — List agents + this.addRoute("PATCH", "/api/approvals/:approvalId", (_req, res, params, token, body) => { + const approvalId = params.approvalId!; + + const decisionSchema = z.object({ + state: z.enum(["approved", "rejected", "revision_requested"]), + decisionNote: z.string().max(400).default(""), + }); + + const parsed = decisionSchema.safeParse(body); + if (!parsed.success) { + this.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); + return; + } + + // Verify the approval exists and belongs to this company + const snapshot = this.db.listSnapshot(); + const approval = snapshot.approvals.find(a => a.id === approvalId && a.companyId === token.companyId); + if (!approval) { + this.json(res, 404, { error: "Approval not found" }); + return; + } + + if (approval.state !== "pending" && approval.state !== "revision_requested") { + this.json(res, 409, { error: "Approval already decided" }); + return; + } + + this.db.decideApproval({ + companyId: token.companyId, + approvalId, + state: parsed.data.state, + decisionNote: parsed.data.decisionNote, + }); + + this.onDomainChanged(); + + // Fire the full approval resolved event chain (automation rules, hire activation, etc.) + this.callbacks.onApprovalResolved(approvalId, token.companyId, parsed.data.state); + + this.json(res, 200, { ok: true, state: parsed.data.state }); + }); + this.addRoute("GET", "/api/companies/:companyId/agents", (_req, res, params, token) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); @@ -845,7 +964,6 @@ export class AgentApiServer { this.json(res, 200, agents); }); - // GET /api/companies/:companyId/goals — List goals this.addRoute("GET", "/api/companies/:companyId/goals", (_req, res, params, token) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); @@ -856,7 +974,6 @@ export class AgentApiServer { this.json(res, 200, goals); }); - // GET /api/companies/:companyId/projects — List projects this.addRoute("GET", "/api/companies/:companyId/projects", (_req, res, params, token) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); @@ -867,9 +984,6 @@ export class AgentApiServer { this.json(res, 200, projects); }); - // GET /api/agents/me/standup — Standup summary for the current agent - // Returns a comprehensive view of the agent's team status, pending reviews, - // blocked tasks, and recent activity — like a daily standup briefing. 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); @@ -889,7 +1003,6 @@ export class AgentApiServer { const myInProgressTasks = myTasks.filter((t) => t.status === "in_progress"); const myTodoTasks = myTasks.filter((t) => t.status === "todo"); - // Recent activity: comments on my tasks in last 2 hours const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; const recentComments = snapshot.comments .filter((c) => { @@ -900,11 +1013,9 @@ export class AgentApiServer { .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, 20); - // Pending approvals I requested const myPendingApprovals = snapshot.approvals.filter( (a) => a.requestedByAgentId === token.agentId && a.state === "pending" && a.companyId === token.companyId, ); - // Recently resolved approvals const myResolvedApprovals = snapshot.approvals.filter( (a) => a.requestedByAgentId === token.agentId && a.state !== "pending" && a.companyId === token.companyId, ).slice(0, 5); @@ -978,9 +1089,6 @@ export class AgentApiServer { }); }); - // ── Browser Automation API (for agents) ──────────────────────────── - - // POST /api/companies/:companyId/browser/action — Submit a browser action this.addRoute("POST", "/api/companies/:companyId/browser/action", async (_req, res, params, token, body) => { const companyId = params.companyId!; if (companyId !== token.companyId) { @@ -990,7 +1098,7 @@ export class AgentApiServer { const actionSchema = z.object({ socialAccountId: z.string().uuid(), - actionType: z.enum(["post", "reply", "like", "follow", "browse_feed", "screenshot", "navigate", "search", "dm", "custom_script"]), + actionType: z.enum(["post", "reply", "like", "follow", "browse_feed", "screenshot", "navigate", "search"]), payload: z.record(z.string(), z.unknown()).default({}), }); @@ -1002,7 +1110,6 @@ export class AgentApiServer { const input = parsed.data; - // Verify the social account belongs to this company const snapshot = this.db.listSnapshot(); const account = snapshot.socialAccounts.find(a => a.id === input.socialAccountId && a.companyId === companyId); if (!account) { @@ -1010,10 +1117,8 @@ export class AgentApiServer { return; } - // Check if approval is required for posting actions const postActions = ["post", "reply", "dm"]; if (account.requireApproval && postActions.includes(input.actionType)) { - // Create an approval request const approvalId = this.db.requestApproval({ companyId, type: "dangerous_command", @@ -1023,7 +1128,6 @@ export class AgentApiServer { impactSummary: `Will ${input.actionType} on ${account.displayName || account.accountName}'s ${account.platform} account. Content will be publicly visible.`, }); - // Save the action in approval_required state const actionId = this.db.saveBrowserAction({ companyId, socialAccountId: input.socialAccountId, @@ -1048,7 +1152,6 @@ export class AgentApiServer { return; } - // Save the action const actionId = this.db.saveBrowserAction({ companyId, socialAccountId: input.socialAccountId, @@ -1069,22 +1172,19 @@ export class AgentApiServer { this.onDomainChanged(); - // Execute the action asynchronously via the browser manager callback if (this.onBrowserAction) { - this.onBrowserAction(actionId, input.socialAccountId, input.actionType, input.payload as Record); + 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" }); }); - // GET /api/companies/:companyId/social-accounts — List social accounts 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!); - // Don't expose credential secret IDs to agents const sanitized = accounts.map(a => ({ id: a.id, platform: a.platform, @@ -1097,7 +1197,6 @@ export class AgentApiServer { this.json(res, 200, sanitized); }); - // GET /api/companies/:companyId/browser/actions — List browser action history this.addRoute("GET", "/api/companies/:companyId/browser/actions", (req, res, params, token) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); @@ -1112,32 +1211,49 @@ export class AgentApiServer { this.json(res, 200, actions); }); - // ── Documents API ───────────────────────────────────────────────── - - // POST /api/companies/:companyId/documents — Create a document 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: data.reviewerAgentId ? String(data.reviewerAgentId) : null, - projectId: data.projectId ? String(data.projectId) : null, - goalId: data.goalId ? String(data.goalId) : null, + 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 }); }); - // GET /api/companies/:companyId/documents — List documents this.addRoute("GET", "/api/companies/:companyId/documents", (req, res, params, token) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); @@ -1148,21 +1264,31 @@ export class AgentApiServer { this.json(res, 200, docs); }); - // ── Meetings API ────────────────────────────────────────────────── - - // POST /api/companies/:companyId/meetings — Schedule a meeting 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: data.participantAgentIds ? String(data.participantAgentIds) : "[]", + participantAgentIds, scheduledAt: String(data.scheduledAt ?? new Date().toISOString()), durationMinutes: Number(data.durationMinutes ?? 30), agendaJson: data.agendaJson ? String(data.agendaJson) : "[]", @@ -1172,7 +1298,6 @@ export class AgentApiServer { this.json(res, 201, { id }); }); - // GET /api/companies/:companyId/meetings — List meetings this.addRoute("GET", "/api/companies/:companyId/meetings", (_req, res, params, token) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); @@ -1183,9 +1308,29 @@ export class AgentApiServer { this.json(res, 200, meetings); }); - // PATCH /api/meetings/:meetingId — Update meeting (add notes, decisions, etc.) 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, @@ -1198,12 +1343,28 @@ export class AgentApiServer { 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 }); }); - // ── Knowledge Base API ──────────────────────────────────────────── - - // POST /api/companies/:companyId/knowledge — Add knowledge entry this.addRoute("POST", "/api/companies/:companyId/knowledge", (_req, res, params, token, body) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); @@ -1225,7 +1386,6 @@ export class AgentApiServer { this.json(res, 201, { id }); }); - // GET /api/companies/:companyId/knowledge — List knowledge entries this.addRoute("GET", "/api/companies/:companyId/knowledge", (_req, res, params, token) => { if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); @@ -1236,9 +1396,6 @@ export class AgentApiServer { this.json(res, 200, entries); }); - // ── Agent Messaging API ────────────────────────────────────────────── - - // POST /api/companies/:companyId/messages — Send a message to another agent this.addRoute("POST", "/api/companies/:companyId/messages", (_req, res, params, token, body) => { const companyId = params.companyId!; if (companyId !== token.companyId) { @@ -1264,6 +1421,18 @@ export class AgentApiServer { } const msg = parsed.data; + if (msg.channel === "direct" && !msg.toAgentId) { + this.json(res, 400, { error: "Direct messages require a recipient" }); + return; + } + if (msg.toAgentId && !this.db.belongsToCompany("agents", msg.toAgentId, companyId)) { + this.json(res, 400, { error: "Direct-message recipient does not belong to the current company" }); + return; + } + if (msg.channel === "project" && msg.channelTargetId && !this.db.belongsToCompany("projects", msg.channelTargetId, companyId)) { + this.json(res, 400, { error: "Project message target does not belong to the current company" }); + return; + } const messageId = this.db.sendAgentMessage({ companyId, fromAgentId: token.agentId, @@ -1277,12 +1446,10 @@ export class AgentApiServer { attachmentsJson: msg.attachmentsJson, }); - // Wake the recipient if it's a direct message if (msg.toAgentId && msg.channel === "direct") { this.onWakeAgent(msg.toAgentId, companyId, "message"); } - // Wake all agents in the department if it's a department broadcast if (msg.channel === "department" && msg.channelTargetId) { const snapshot = this.db.listSnapshot(); const deptAgents = snapshot.agents.filter( @@ -1293,11 +1460,19 @@ export class AgentApiServer { } } + // For company-wide messages, wake the CEO so leadership responds + if (msg.channel === "company") { + const snapshot = this.db.listSnapshot(); + const ceo = snapshot.agents.find((a) => a.companyId === companyId && !a.reportsTo && a.status !== "terminated"); + if (ceo && ceo.id !== token.agentId) { + this.onWakeAgent(ceo.id, companyId, "message"); + } + } + this.onDomainChanged(); this.json(res, 201, { id: messageId }); }); - // GET /api/companies/:companyId/messages — List messages for the calling agent this.addRoute("GET", "/api/companies/:companyId/messages", (req, res, params, token) => { const companyId = params.companyId!; if (companyId !== token.companyId) { @@ -1320,20 +1495,30 @@ export class AgentApiServer { this.json(res, 200, messages); }); - // POST /api/companies/:companyId/messages/:messageId/read — Mark message as read 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; } - this.db.markMessageRead(params.messageId!, companyId); + 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 }).some((entry) => entry.id === message.id); + if (!visibleToAgent) { + this.json(res, 403, { error: "This message is not visible to the current agent" }); + return; + } + if (message.channel === "direct" && message.toAgentId !== token.agentId) { + this.json(res, 403, { error: "Only the direct recipient can mark this message as read" }); + return; + } + this.db.markAgentMessageRead(params.messageId!, companyId, token.agentId); this.json(res, 200, { ok: true }); }); - // ── Sprint API ─────────────────────────────────────────────────────── - - // GET /api/companies/:companyId/sprints — List sprints this.addRoute("GET", "/api/companies/:companyId/sprints", (_req, res, params, token) => { const companyId = params.companyId!; if (companyId !== token.companyId) { @@ -1345,9 +1530,32 @@ export class AgentApiServer { this.json(res, 200, sprints); }); - // ── Performance Metrics API ────────────────────────────────────────── + 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" }); + } + }); - // GET /api/agents/me/metrics — Get the calling agent's performance metrics 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); @@ -1358,9 +1566,6 @@ export class AgentApiServer { this.json(res, 200, agentMetric); }); - // ── Org Chart / Agent Details API ──────────────────────────────────── - - // GET /api/agents/:agentId — Get a specific agent's details 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); @@ -1377,9 +1582,6 @@ export class AgentApiServer { }); }); - // ── Wake / Heartbeat API ───────────────────────────────────────────── - - // POST /api/agents/:agentId/wake — Request to wake another agent this.addRoute("POST", "/api/agents/:agentId/wake", (_req, res, params, token, body) => { const targetAgentId = params.agentId!; const wakeSchema = z.object({ @@ -1387,8 +1589,84 @@ export class AgentApiServer { }); const parsed = wakeSchema.safeParse(body); const reason = parsed.success ? parsed.data.reason : "peer_request"; - this.onWakeAgent(targetAgentId, token.companyId, reason); - this.json(res, 200, { ok: true, message: `Wake requested for agent ${targetAgentId}` }); + 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; + } + 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, + })), + }); }); } } diff --git a/src/main/browser-manager.ts b/src/main/browser-manager.ts index 6eda8628..079cdeb1 100644 --- a/src/main/browser-manager.ts +++ b/src/main/browser-manager.ts @@ -3,19 +3,10 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import log from "electron-log/main.js"; -/** - * Browser Automation Manager — Electron-native edition. - * - * Uses Electron's own BrowserWindow and persistent sessions instead of - * external Playwright, so it works in packaged apps with zero extra deps. - * - * Each social account gets its own Electron session partition so cookies, - * localStorage, and login state are isolated and persisted across app restarts. - */ - export interface BrowserAction { id: string; socialAccountId: string; + platform: string; actionType: string; payload: Record; } @@ -33,56 +24,116 @@ interface QueuedAction { reject: (error: Error) => void; } +interface PlatformRule { + loginUrl: string; + authCheckUrl: string; + allowedHosts: string[]; + cookieOrigins: string[]; + unauthenticatedUrlMarkers: string[]; +} + +const PLATFORM_RULES: Record = { + twitter: { + loginUrl: "https://x.com/i/flow/login", + authCheckUrl: "https://x.com/home", + allowedHosts: ["x.com", "twitter.com"], + cookieOrigins: ["https://x.com", "https://twitter.com"], + unauthenticatedUrlMarkers: ["/i/flow/login", "/login"], + }, + linkedin: { + loginUrl: "https://www.linkedin.com/login", + authCheckUrl: "https://www.linkedin.com/feed/", + allowedHosts: ["linkedin.com"], + cookieOrigins: ["https://www.linkedin.com"], + unauthenticatedUrlMarkers: ["/login", "/checkpoint"], + }, + instagram: { + loginUrl: "https://www.instagram.com/accounts/login/", + authCheckUrl: "https://www.instagram.com/", + allowedHosts: ["instagram.com"], + cookieOrigins: ["https://www.instagram.com"], + unauthenticatedUrlMarkers: ["/accounts/login"], + }, + facebook: { + loginUrl: "https://www.facebook.com/login", + authCheckUrl: "https://www.facebook.com/", + allowedHosts: ["facebook.com"], + cookieOrigins: ["https://www.facebook.com"], + unauthenticatedUrlMarkers: ["/login"], + }, + reddit: { + loginUrl: "https://www.reddit.com/login", + authCheckUrl: "https://www.reddit.com/", + allowedHosts: ["reddit.com"], + cookieOrigins: ["https://www.reddit.com"], + unauthenticatedUrlMarkers: ["/login"], + }, + youtube: { + loginUrl: "https://accounts.google.com/signin", + authCheckUrl: "https://www.youtube.com/", + allowedHosts: ["youtube.com", "google.com"], + cookieOrigins: ["https://www.youtube.com", "https://accounts.google.com"], + unauthenticatedUrlMarkers: ["/signin", "/ServiceLogin"], + }, + tiktok: { + loginUrl: "https://www.tiktok.com/login", + authCheckUrl: "https://www.tiktok.com/foryou", + allowedHosts: ["tiktok.com"], + cookieOrigins: ["https://www.tiktok.com"], + unauthenticatedUrlMarkers: ["/login"], + }, + bluesky: { + loginUrl: "https://bsky.app", + authCheckUrl: "https://bsky.app/", + allowedHosts: ["bsky.app"], + cookieOrigins: ["https://bsky.app"], + unauthenticatedUrlMarkers: ["/login"], + }, + other: { + loginUrl: "https://google.com", + authCheckUrl: "https://google.com", + allowedHosts: ["google.com"], + cookieOrigins: ["https://google.com"], + unauthenticatedUrlMarkers: ["/login"], + }, +}; + export class BrowserManager { private screenshotDir: string; private queues = new Map(); private processing = new Set(); - /** Track open login windows so we don't open duplicates */ private loginWindows = new Map(); + private initializedSessions = new Set(); constructor(profileDir: string) { this.screenshotDir = join(profileDir, "browser-screenshots"); if (!existsSync(this.screenshotDir)) mkdirSync(this.screenshotDir, { recursive: true }); } - /** - * Get a persistent Electron session for a social account. - * Using `persist:` prefix ensures data survives app restarts. - */ private getSession(socialAccountId: string): Electron.Session { - return session.fromPartition(`persist:social-${socialAccountId}`); + const partition = `persist:social-${socialAccountId}`; + const socialSession = session.fromPartition(partition); + if (!this.initializedSessions.has(partition)) { + socialSession.setPermissionCheckHandler(() => false); + socialSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); + this.initializedSessions.add(partition); + } + return socialSession; } - /** - * Launch a real browser window for manual login. - * The window uses a persistent session so cookies are saved. - * Returns a promise that resolves when the user closes the window. - */ async launchLoginBrowser( socialAccountId: string, platform: string, loginUrl?: string, ): Promise { - // Don't open duplicate login windows for the same account const existing = this.loginWindows.get(socialAccountId); if (existing && !existing.isDestroyed()) { existing.focus(); return { success: true, summary: "Login window already open", screenshotPath: null, error: null }; } - const defaultUrls: Record = { - twitter: "https://x.com/i/flow/login", - linkedin: "https://www.linkedin.com/login", - instagram: "https://www.instagram.com/accounts/login/", - facebook: "https://www.facebook.com/login", - reddit: "https://www.reddit.com/login", - youtube: "https://accounts.google.com/signin", - tiktok: "https://www.tiktok.com/login", - bluesky: "https://bsky.app", - other: "https://google.com", - }; - - const url = loginUrl || defaultUrls[platform] || defaultUrls.other; + const rules = this.getPlatformRules(platform); + const url = loginUrl || rules.loginUrl; const ses = this.getSession(socialAccountId); log.info(`[browser] Opening login window for ${platform} → ${url}`); @@ -93,7 +144,6 @@ export class BrowserManager { title: `Login to ${platform} — AgentCompany`, webPreferences: { session: ses, - // Standard web page — no node integration, no preload nodeIntegration: false, contextIsolation: true, sandbox: true, @@ -102,43 +152,32 @@ export class BrowserManager { this.loginWindows.set(socialAccountId, win); - // Remove menu bar for a cleaner look win.setMenuBarVisibility(false); - // Load the login page try { await win.loadURL(url); } catch (err) { log.error(`[browser] Failed to load ${url}: ${err}`); - // Don't fail — the page might redirect or have an expected error } - // Wait for the user to close the window return new Promise((resolve) => { win.on("closed", () => { this.loginWindows.delete(socialAccountId); - log.info(`[browser] Login window closed for ${platform} — session saved`); - resolve({ - success: true, - summary: `Login session saved for ${platform}`, - screenshotPath: null, - error: null, + void this.verifyLoginSession(socialAccountId, platform).then((verified) => { + log.info(`[browser] Login window closed for ${platform} — verified=${verified}`); + resolve({ + success: verified, + summary: verified + ? `Authenticated session detected for ${platform}` + : `No authenticated ${platform} session detected`, + screenshotPath: null, + error: verified ? null : `Authentication for ${platform} was not completed.`, + }); }); }); }); } - /** - * Check if browser automation is available. - * Always true — we use Electron's own Chromium. - */ - async checkPlaywright(): Promise { - return true; - } - - /** - * Queue a browser action for execution. - */ async executeAction(action: BrowserAction): Promise { return new Promise((resolve, reject) => { const queue = this.queues.get(action.socialAccountId) ?? []; @@ -166,17 +205,14 @@ export class BrowserManager { this.processing.delete(socialAccountId); } - /** - * Execute a single browser action using an Electron BrowserWindow. - */ private async runAction(action: BrowserAction): Promise { const ses = this.getSession(action.socialAccountId); const payload = action.payload; const screenshotPath = join(this.screenshotDir, `${action.id}.png`); + const platform = action.platform; - log.info(`[browser] Executing ${action.actionType} for account ${action.socialAccountId}`); + log.info(`[browser] Executing ${action.actionType} for ${platform}/${action.socialAccountId}`); - // Create a visible browser window for the action const win = new BrowserWindow({ width: 1280, height: 860, @@ -197,6 +233,7 @@ export class BrowserManager { switch (action.actionType) { case "navigate": { const url = String(payload.url || "https://google.com"); + this.assertAllowedUrl(platform, url, "navigate"); await win.loadURL(url); await this.waitForLoad(win); const title = await win.webContents.executeJavaScript("document.title"); @@ -205,7 +242,6 @@ export class BrowserManager { } case "post": { - const platform = String(payload.platform || "twitter"); const content = String(payload.content || ""); const postUrl = this.getComposeUrl(platform); await win.loadURL(postUrl); @@ -213,7 +249,6 @@ export class BrowserManager { await this.delay(2000); if (platform === "twitter") { - // Type into tweet composer await win.webContents.executeJavaScript(` (async () => { const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"]'); @@ -224,7 +259,6 @@ export class BrowserManager { })() `); await this.delay(1000); - // Click post button await win.webContents.executeJavaScript(` (() => { const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]'); @@ -264,6 +298,7 @@ export class BrowserManager { case "reply": { const targetUrl = String(payload.targetUrl || ""); + this.assertAllowedUrl(platform, targetUrl, "reply"); const replyContent = String(payload.content || ""); await win.loadURL(targetUrl); await this.waitForLoad(win); @@ -292,6 +327,7 @@ export class BrowserManager { case "like": { const targetUrl = String(payload.targetUrl || ""); + this.assertAllowedUrl(platform, targetUrl, "like"); await win.loadURL(targetUrl); await this.waitForLoad(win); await this.delay(2000); @@ -308,6 +344,7 @@ export class BrowserManager { case "follow": { const targetUrl = String(payload.targetUrl || payload.profileUrl || ""); + this.assertAllowedUrl(platform, targetUrl, "follow"); await win.loadURL(targetUrl); await this.waitForLoad(win); await this.delay(2000); @@ -323,14 +360,13 @@ export class BrowserManager { } case "browse_feed": { - const platform = String(payload.platform || "twitter"); const feedUrl = String(payload.url || this.getFeedUrl(platform)); + this.assertAllowedUrl(platform, feedUrl, "browse_feed"); const maxPosts = Number(payload.maxPosts || 10); await win.loadURL(feedUrl); await this.waitForLoad(win); await this.delay(3000); - // Scroll to load content for (let i = 0; i < 3; i++) { await win.webContents.executeJavaScript("window.scrollBy(0, window.innerHeight)"); await this.delay(1500); @@ -348,8 +384,8 @@ export class BrowserManager { case "search": { const query = String(payload.query || ""); - const platform = String(payload.platform || "twitter"); const searchUrl = this.getSearchUrl(platform, query); + this.assertAllowedUrl(platform, searchUrl, "search"); await win.loadURL(searchUrl); await this.waitForLoad(win); await this.delay(3000); @@ -367,6 +403,7 @@ export class BrowserManager { case "screenshot": { const url = String(payload.url || "about:blank"); if (url !== "about:blank") { + this.assertAllowedUrl(platform, url, "screenshot"); await win.loadURL(url); await this.waitForLoad(win); await this.delay(2000); @@ -377,10 +414,9 @@ export class BrowserManager { } default: - summary = `Unknown action type: ${action.actionType}`; + throw new Error(`Unsupported browser action type: ${action.actionType}`); } - // Capture screenshot try { const image: NativeImage = await win.webContents.capturePage(); if (!image.isEmpty()) { @@ -390,7 +426,6 @@ export class BrowserManager { log.warn(`[browser] Screenshot failed: ${err}`); } - // Close the action window after a brief pause so user can see result await this.delay(1000); if (!win.isDestroyed()) win.close(); @@ -401,7 +436,6 @@ export class BrowserManager { error: null, }; } catch (error) { - // Try to capture error screenshot try { const image: NativeImage = await win.webContents.capturePage(); if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG()); @@ -420,8 +454,6 @@ export class BrowserManager { } } - // ── Helpers ────────────────────────────────────────────────────── - private delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -440,6 +472,69 @@ export class BrowserManager { }); } + private getPlatformRules(platform: string): PlatformRule { + return PLATFORM_RULES[platform] ?? PLATFORM_RULES.other; + } + + private assertAllowedUrl(platform: string, rawUrl: string, actionType: string) { + const allowedHosts = this.getPlatformRules(platform).allowedHosts; + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new Error(`Invalid URL for ${actionType}: ${rawUrl}`); + } + const host = url.hostname.toLowerCase(); + const isAllowed = allowedHosts.some((allowedHost) => host === allowedHost || host.endsWith(`.${allowedHost}`)); + if (!isAllowed) { + throw new Error(`Navigation blocked for ${actionType}. ${host} is outside the allowed ${platform} domains.`); + } + } + + private async verifyLoginSession(socialAccountId: string, platform: string): Promise { + const ses = this.getSession(socialAccountId); + const rules = this.getPlatformRules(platform); + const cookieSets = await Promise.all( + rules.cookieOrigins.map(async (origin) => { + try { + return await ses.cookies.get({ url: origin }); + } catch { + return []; + } + }), + ); + const hasCookies = cookieSets.some((cookies) => cookies.length > 0); + if (!hasCookies) { + return false; + } + + const verificationWindow = new BrowserWindow({ + show: false, + webPreferences: { + session: ses, + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + }, + }); + + try { + await verificationWindow.loadURL(rules.authCheckUrl); + await this.waitForLoad(verificationWindow, 10000); + const finalUrl = verificationWindow.webContents.getURL(); + const finalUrlLower = finalUrl.toLowerCase(); + const bouncedToLogin = rules.unauthenticatedUrlMarkers.some((marker) => finalUrlLower.includes(marker.toLowerCase())); + return !bouncedToLogin; + } catch (error) { + log.warn(`[browser] Login verification failed for ${platform}: ${error instanceof Error ? error.message : String(error)}`); + return false; + } finally { + if (!verificationWindow.isDestroyed()) { + verificationWindow.close(); + } + } + } + private getComposeUrl(platform: string): string { const urls: Record = { twitter: "https://x.com/compose/post", @@ -448,8 +543,10 @@ export class BrowserManager { facebook: "https://www.facebook.com/", reddit: "https://www.reddit.com/submit", bluesky: "https://bsky.app/", + youtube: "https://www.youtube.com/", + tiktok: "https://www.tiktok.com/", }; - return urls[platform] || "https://google.com"; + return urls[platform] || this.getPlatformRules(platform).loginUrl; } private getFeedUrl(platform: string): string { @@ -457,10 +554,13 @@ export class BrowserManager { twitter: "https://x.com/home", linkedin: "https://www.linkedin.com/feed/", instagram: "https://www.instagram.com/", + facebook: "https://www.facebook.com/", reddit: "https://www.reddit.com/", bluesky: "https://bsky.app/", + youtube: "https://www.youtube.com/", + tiktok: "https://www.tiktok.com/", }; - return urls[platform] || "https://google.com"; + return urls[platform] || this.getPlatformRules(platform).loginUrl; } private getSearchUrl(platform: string, query: string): string { @@ -474,7 +574,6 @@ export class BrowserManager { } shutdown() { - // Close any open login windows for (const [, win] of this.loginWindows) { if (!win.isDestroyed()) win.close(); } diff --git a/src/main/connectors.ts b/src/main/connectors.ts index fc2e6aa1..a330a4c8 100644 --- a/src/main/connectors.ts +++ b/src/main/connectors.ts @@ -263,10 +263,6 @@ const CONNECTORS: ConnectorDefinition[] = [ }, ]; -export function listConnectorDefinitions(): ConnectorDefinition[] { - return CONNECTORS; -} - export function getConnectorDefinition(id: ConnectorId): ConnectorDefinition { const definition = CONNECTORS.find((entry) => entry.id === id); if (!definition) { diff --git a/src/main/database.ts b/src/main/database.ts index dbfc67e7..6514ec1e 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -7,6 +7,7 @@ import { getConnectorDefinition, buildDefaultConnectorRows, parseEnvBindingText import { SecretVault } from "./vault"; import type { ActivityRecord, + AgentMessageRecord, AgentMetrics, AgentRecord, ApprovalRecord, @@ -27,10 +28,12 @@ import type { SocialAccountRecord, StandupReport, TaskRecord, + WorkflowPipelineRecord, WorkspaceRecord, } from "@shared/types"; type SqliteDatabase = DatabaseSync; +type SqliteRow = Record; function nowIso() { return new Date().toISOString(); @@ -284,9 +287,13 @@ export class AppDatabase { create index if not exists idx_cost_entries_company on cost_entries(company_id); create index if not exists idx_cost_entries_run on cost_entries(run_id); create index if not exists idx_activity_company on activity(company_id); + create index if not exists idx_tasks_company_status on tasks(company_id, status); + create index if not exists idx_agents_company_status on agents(company_id, status); + create index if not exists idx_agents_reports_to on agents(reports_to); + create index if not exists idx_runs_company_status on runs(company_id, status); + create index if not exists idx_approvals_company_state on approvals(company_id, state); `); - // Step 3: Deferred wake queue — prevents silent wake loss when agent is busy this.db.exec(` create table if not exists pending_wakes ( id text primary key, @@ -301,27 +308,26 @@ export class AppDatabase { this.migrateSecretsTable(); this.ensureOptionalColumns(); - // secrets index must run AFTER migrateSecretsTable adds company_id column this.db.exec(`create index if not exists idx_secrets_company on secrets(company_id);`); - // Forward-only migration system this.runMigrations(); + // Create indexes that depend on columns added by migrations (e.g., department) + try { + this.db.exec(`create index if not exists idx_agents_department on agents(company_id, department);`); + } catch { /* column may not exist if migrations failed */ } + this.seedSettings(); this.seedConnectors(); this.interruptStaleRuns(); - // Purge expired run logs based on retention setting this.purgeExpiredRunLogs(); + this.purgeExpiredPendingWakes(); } close() { this.db.close(); } - getProfilePath() { - return this.profileDir; - } - getCurrentCompanyId() { return this.getSetting("current_company_id"); } @@ -335,7 +341,7 @@ export class AppDatabase { return Boolean(row); } - belongsToCompany(table: "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets", id: string, companyId: string) { + belongsToCompany(table: "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets" | "meetings" | "documents" | "knowledge_base" | "sprints" | "automation_rules" | "workflow_pipelines", id: string, companyId: string) { const queries: Record = { workspaces: "select 1 as present from workspaces where id = ? and company_id = ?", agents: "select 1 as present from agents where id = ? and company_id = ?", @@ -345,6 +351,12 @@ export class AppDatabase { approvals: "select 1 as present from approvals where id = ? and company_id = ?", runs: "select 1 as present from runs where id = ? and company_id = ?", secrets: "select 1 as present from secrets where id = ? and company_id = ?", + meetings: "select 1 as present from meetings where id = ? and company_id = ?", + documents: "select 1 as present from documents where id = ? and company_id = ?", + knowledge_base: "select 1 as present from knowledge_base where id = ? and company_id = ?", + sprints: "select 1 as present from sprints where id = ? and company_id = ?", + automation_rules: "select 1 as present from automation_rules where id = ? and company_id = ?", + workflow_pipelines: "select 1 as present from workflow_pipelines where id = ? and company_id = ?", }; const sql = queries[table]; if (!sql) throw new Error(`Unknown table: ${table}`); @@ -352,6 +364,23 @@ export class AppDatabase { return Boolean(row?.present); } + hasActiveRun(filter: { companyId: string; taskId?: string; agentId?: string }) { + const whereClauses = ["company_id = ?", "status in ('queued', 'running')"]; + const params: SQLInputValue[] = [filter.companyId]; + if (filter.taskId) { + whereClauses.push("task_id = ?"); + params.push(filter.taskId); + } + if (filter.agentId) { + whereClauses.push("agent_id = ?"); + params.push(filter.agentId); + } + const row = this.db.prepare( + `select 1 as present from runs where ${whereClauses.join(" and ")} limit 1`, + ).get(...params) as { present: number } | undefined; + return Boolean(row?.present); + } + canOpenPath(targetPath: string) { if (!targetPath || !existsSync(targetPath)) { return false; @@ -389,11 +418,6 @@ export class AppDatabase { return Number.isFinite(parsed) && parsed >= 1 ? parsed : 90; // default 90 days } - /** - * Purge run logs older than the retention window. - * Deletes the log file and clears the log_path column. - * Returns the number of runs purged. - */ purgeExpiredRunLogs(): number { const retentionDays = this.getLogRetentionDays(); const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString(); @@ -407,7 +431,7 @@ export class AppDatabase { try { unlinkSync(run.logPath); } catch { - // File may already be gone — that's fine + // Log files may already be gone; continue clearing DB references. } } this.db @@ -466,9 +490,9 @@ export class AppDatabase { comments: this.readAll( "select id, company_id as companyId, task_id as taskId, author_agent_id as authorAgentId, author_name as authorName, body, created_at as createdAt from task_comments order by created_at asc", ), - socialAccounts: this.readAll( + socialAccounts: this.readAll( "select id, company_id, platform, account_name, display_name, profile_url, credential_secret_id, status, require_approval, metadata_json, last_used_at, created_at, updated_at from social_accounts order by created_at desc", - ).map((r: any) => ({ + ).map((r) => ({ id: r.id, companyId: r.company_id, platform: r.platform, @@ -483,42 +507,41 @@ export class AppDatabase { createdAt: r.created_at, updatedAt: r.updated_at, })) as SocialAccountRecord[], - browserActions: [], - meetings: this.readAll( + meetings: this.readAll( "select id, company_id, type, title, organizer_agent_id, participant_agent_ids, scheduled_at, duration_minutes, agenda_json, notes_json, decisions_json, action_items_json, status, created_at, updated_at from meetings order by scheduled_at desc", - ).map((r: any) => ({ + ).map((r) => ({ id: r.id, companyId: r.company_id, type: r.type, title: r.title, organizerAgentId: r.organizer_agent_id, participantAgentIds: r.participant_agent_ids, scheduledAt: r.scheduled_at, durationMinutes: r.duration_minutes, agendaJson: r.agenda_json, notesJson: r.notes_json, decisionsJson: r.decisions_json, actionItemsJson: r.action_items_json, status: r.status, createdAt: r.created_at, updatedAt: r.updated_at, })), - documents: this.readAll( + documents: this.readAll( "select id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, created_at, updated_at from documents order by updated_at desc", - ).map((r: any) => ({ + ).map((r) => ({ id: r.id, companyId: r.company_id, type: r.type, title: r.title, content: r.content, authorAgentId: r.author_agent_id, reviewerAgentId: r.reviewer_agent_id, projectId: r.project_id, goalId: r.goal_id, parentDocId: r.parent_doc_id, version: r.version, status: r.status, tagsJson: r.tags_json, createdAt: r.created_at, updatedAt: r.updated_at, })), - knowledgeBase: this.readAll( + knowledgeBase: this.readAll( "select id, company_id, category, topic, content, author_agent_id, importance, tags_json, referenced_entity_type, referenced_entity_id, created_at, updated_at from knowledge_base order by updated_at desc", - ).map((r: any) => ({ + ).map((r) => ({ id: r.id, companyId: r.company_id, category: r.category, topic: r.topic, content: r.content, authorAgentId: r.author_agent_id, importance: r.importance, tagsJson: r.tags_json, referencedEntityType: r.referenced_entity_type, referencedEntityId: r.referenced_entity_id, createdAt: r.created_at, updatedAt: r.updated_at, })), - sprints: this.readAll( + sprints: this.readAll( "select id, company_id, name, goal, start_date, end_date, status, retrospective_notes, velocity_points, completed_points, created_at, updated_at from sprints order by start_date desc", - ).map((r: any) => ({ + ).map((r) => ({ id: r.id, companyId: r.company_id, name: r.name, goal: r.goal, startDate: r.start_date, endDate: r.end_date, status: r.status, retrospectiveNotes: r.retrospective_notes, velocityPoints: r.velocity_points, completedPoints: r.completed_points, createdAt: r.created_at, updatedAt: r.updated_at, })), - automationRules: this.readAll( + automationRules: this.readAll( "select id, company_id, name, description, trigger, conditions_json, action, action_config_json, source_department, target_department, priority, status, execution_count, last_executed_at, created_at, updated_at from automation_rules order by priority desc, created_at desc", - ).map((r: any) => ({ + ).map((r) => ({ id: r.id, companyId: r.company_id, name: r.name, description: r.description, trigger: r.trigger, conditionsJson: r.conditions_json, action: r.action, actionConfigJson: r.action_config_json, sourceDepartment: r.source_department, @@ -526,18 +549,18 @@ export class AppDatabase { executionCount: r.execution_count, lastExecutedAt: r.last_executed_at, createdAt: r.created_at, updatedAt: r.updated_at, })), - agentMessages: this.readAll( - "select id, company_id, from_agent_id, to_agent_id, channel, channel_target_id, subject, body, priority, read_at, parent_message_id, attachments_json, created_at from agent_messages order by created_at desc limit 200", - ).map((r: any) => ({ + agentMessages: this.readAll( + "select id, company_id, from_agent_id, to_agent_id, channel, channel_target_id, subject, body, priority, read_at, parent_message_id, attachments_json, created_at from agent_messages order by created_at desc limit 500", + ).map((r) => ({ id: r.id, companyId: r.company_id, fromAgentId: r.from_agent_id, toAgentId: r.to_agent_id, channel: r.channel, channelTargetId: r.channel_target_id, subject: r.subject, body: r.body, priority: r.priority, readAt: r.read_at, parentMessageId: r.parent_message_id, attachmentsJson: r.attachments_json, createdAt: r.created_at, })), - workflows: this.readAll( + workflows: this.readAll( "select id, company_id, name, description, steps_json, trigger_type, trigger_config_json, status, current_step_index, run_count, last_run_at, created_at, updated_at from workflow_pipelines order by created_at desc", - ).map((r: any) => ({ + ).map((r) => ({ id: r.id, companyId: r.company_id, name: r.name, description: r.description, stepsJson: r.steps_json, triggerType: r.trigger_type, triggerConfigJson: r.trigger_config_json, status: r.status, currentStepIndex: r.current_step_index, runCount: r.run_count, @@ -546,6 +569,37 @@ export class AppDatabase { }; } + listRendererSnapshot(): ProfileSnapshot { + const snapshot = this.listSnapshot(); + const companyId = snapshot.currentCompanyId; + if (!companyId) { + return snapshot; + } + + return { + ...snapshot, + workspaces: snapshot.workspaces.filter((workspace) => workspace.companyId === companyId), + agents: snapshot.agents.filter((agent) => agent.companyId === companyId), + goals: snapshot.goals.filter((goal) => goal.companyId === companyId), + projects: snapshot.projects.filter((project) => project.companyId === companyId), + tasks: snapshot.tasks.filter((task) => task.companyId === companyId), + approvals: snapshot.approvals.filter((approval) => approval.companyId === companyId), + runs: snapshot.runs.filter((run) => run.companyId === companyId), + costs: snapshot.costs.filter((cost) => cost.companyId === companyId), + activity: snapshot.activity.filter((entry) => entry.companyId === companyId), + secrets: snapshot.secrets.filter((secret) => secret.companyId === companyId), + comments: snapshot.comments.filter((comment) => comment.companyId === companyId), + socialAccounts: snapshot.socialAccounts.filter((account) => account.companyId === companyId), + meetings: snapshot.meetings.filter((meeting) => meeting.companyId === companyId), + documents: snapshot.documents.filter((document) => document.companyId === companyId), + knowledgeBase: snapshot.knowledgeBase.filter((entry) => entry.companyId === companyId), + sprints: snapshot.sprints.filter((sprint) => sprint.companyId === companyId), + automationRules: snapshot.automationRules.filter((rule) => rule.companyId === companyId), + agentMessages: snapshot.agentMessages.filter((message) => message.companyId === companyId), + workflows: snapshot.workflows.filter((workflow) => workflow.companyId === companyId), + }; + } + saveCompany(input: { id?: string | null; name: string; description: string; status: string; autoApproveHires?: boolean }) { const id = ensureId(input.id ?? null); const ts = nowIso(); @@ -573,12 +627,11 @@ export class AppDatabase { envBindingText: string; notes: string; }) { - const definition = getConnectorDefinition(input.id); this.db .prepare( "update connectors set command = ?, model = ?, env_binding_text = ?, notes = ?, updated_at = ? where id = ?", ) - .run(definition.defaultCommand, input.model, input.envBindingText, input.notes, nowIso(), input.id); + .run(input.command, input.model, input.envBindingText, input.notes, nowIso(), input.id); } updateConnectorHealth(input: { @@ -789,7 +842,6 @@ export class AppDatabase { }) { const id = randomUUID(); const ts = nowIso(); - // Extract relatedAgentId from payload for hire_agent approvals if not explicitly set let relatedAgentId = normalizeOptionalId(input.relatedAgentId); if (!relatedAgentId && input.type === "hire_agent" && input.payloadJson) { try { @@ -810,16 +862,19 @@ export class AppDatabase { input.type, input.payloadSummary, input.impactSummary, - normalizeOptionalId(input.payloadJson), + input.payloadJson ?? null, ts, ts, ); return id; } - decideApproval(input: { approvalId: string; state: string; decisionNote: string }) { + decideApproval(input: { companyId?: string | null; approvalId: string; state: string; decisionNote: string }) { const approval = this.getApprovalRecord(input.approvalId); - if (approval.state !== "pending") { + if (input.companyId && approval.companyId !== input.companyId) { + throw new Error(`Approval ${input.approvalId} does not belong to company ${input.companyId}.`); + } + if (!["pending", "revision_requested"].includes(approval.state)) { throw new Error(`Approval ${input.approvalId} is already ${approval.state} and cannot be re-decided.`); } const ts = nowIso(); @@ -828,21 +883,21 @@ export class AppDatabase { .prepare("update approvals set state = ?, decision_note = ?, updated_at = ? where id = ?") .run(input.state, input.decisionNote, ts, input.approvalId); if (approval.type === "hire_agent") { - // Prefer structured related_agent_id; fall back to payload_json for legacy records const agentId = approval.relatedAgentId ?? parseJson<{ agentId?: string } | null>(approval.payloadJson, null)?.agentId ?? null; - if (agentId) { - if (input.state === "approved") { - this.db - .prepare("update agents set status = 'idle', updated_at = ? where id = ? and company_id = ? and status = 'pending_approval'") - .run(ts, agentId, approval.companyId); - } - if (input.state === "rejected") { - this.db - .prepare("update agents set status = 'terminated', updated_at = ? where id = ? and company_id = ? and status = 'pending_approval'") - .run(ts, agentId, approval.companyId); - } + if (!agentId) { + throw new Error(`hire_agent approval ${input.approvalId} has no linked agent — cannot resolve hire target. Ensure relatedAgentId or payloadJson.agentId is set.`); + } + if (input.state === "approved") { + this.db + .prepare("update agents set status = 'idle', updated_at = ? where id = ? and company_id = ? and status = 'pending_approval'") + .run(ts, agentId, approval.companyId); + } + if (input.state === "rejected") { + this.db + .prepare("update agents set status = 'terminated', updated_at = ? where id = ? and company_id = ? and status = 'pending_approval'") + .run(ts, agentId, approval.companyId); } } }); @@ -929,16 +984,19 @@ export class AppDatabase { return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null; } - getProjectPrimaryWorkspace(companyId: string): WorkspaceRecord | null { + getProjectPrimaryWorkspace(projectId: string | null | undefined, companyId: string): WorkspaceRecord | null { + if (!projectId) { + return null; + } + const row = this.db .prepare( - "select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces where company_id = ? and is_primary = 1 order by created_at asc limit 1", + "select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces where company_id = ? and project_id = ? order by is_primary desc, created_at asc limit 1", ) - .get(companyId) as WorkspaceRecord | undefined; + .get(companyId, projectId) as WorkspaceRecord | undefined; return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null; } - /** Get any workspace in the company (last resort fallback). */ getAnyCompanyWorkspace(companyId: string): WorkspaceRecord | null { const row = this.db .prepare( @@ -1072,6 +1130,25 @@ export class AppDatabase { return { budgetHardStopped }; } + private cancelRunForDeletion(runId: string, taskId: string, workspaceId: string | null, summary: string) { + const ts = nowIso(); + this.db + .prepare( + "update runs set status = 'cancelled', summary = ?, error_message = null, exit_code = null, signal = null, model = null, session_display_id = null, cost_usd = null, unattributed_cost = 1, finished_at = ?, updated_at = ? where id = ?", + ) + .run(summary, ts, ts, runId); + this.db.prepare("update tasks set active_run_id = null, updated_at = ? where id = ?").run(ts, taskId); + if (workspaceId) { + this.db + .prepare( + `update workspaces + set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ? + where id = ? and lock_holder_run_id = ?`, + ) + .run(ts, workspaceId, runId); + } + } + updateTaskSession(taskId: string, sessionJson: Record | null, sessionDisplayId: string | null) { this.db .prepare("update tasks set session_json = ?, session_display_id = ?, updated_at = ? where id = ?") @@ -1096,6 +1173,13 @@ export class AppDatabase { deleteCompany(companyId: string) { this.transaction(() => { + try { + this.db.prepare( + "delete from agent_message_reads where message_id in (select id from agent_messages where company_id = ?)", + ).run(companyId); + } catch (err) { + if (!(err instanceof Error) || !err.message.includes("no such table")) throw err; + } this.db.prepare("delete from pending_wakes where company_id = ?").run(companyId); this.db.prepare("delete from cost_entries where company_id = ?").run(companyId); this.db.prepare("delete from activity where company_id = ?").run(companyId); @@ -1108,7 +1192,6 @@ export class AppDatabase { this.db.prepare("delete from agents where company_id = ?").run(companyId); this.db.prepare("delete from secrets where company_id = ?").run(companyId); this.db.prepare("delete from workspaces where company_id = ?").run(companyId); - // Clean up tables added by migrations (safe to call — migrations run on init) for (const table of ["automation_rules", "automation_log", "agent_messages", "workflow_pipelines", "meetings", "documents", "knowledge_base", "sprints"]) { try { this.db.prepare(`delete from ${table} where company_id = ?`).run(companyId); @@ -1118,7 +1201,6 @@ export class AppDatabase { } } this.db.prepare("delete from companies where id = ?").run(companyId); - // Switch to another company if the deleted one was current if (this.getCurrentCompanyId() === companyId) { const next = this.db.prepare("select id from companies order by created_at asc limit 1").get() as { id: string } | undefined; if (next) { @@ -1131,64 +1213,71 @@ export class AppDatabase { } deleteAgent(id: string, companyId: string) { + const activeAgentRuns = this.readAll<{ id: string; taskId: string; workspaceId: string | null }>( + "select id, task_id as taskId, workspace_id as workspaceId from runs where agent_id = ? and company_id = ? and status in ('queued', 'running')", + id, companyId, + ); this.transaction(() => { - // Clear references from tasks + for (const run of activeAgentRuns) { + this.cancelRunForDeletion(run.id, run.taskId, run.workspaceId, "Agent deleted."); + } this.db.prepare("update tasks set assignee_agent_id = null where assignee_agent_id = ? and company_id = ?").run(id, companyId); - // Clear references from goals this.db.prepare("update goals set owner_agent_id = null where owner_agent_id = ? and company_id = ?").run(id, companyId); - // Clear references from projects this.db.prepare("update projects set lead_agent_id = null where lead_agent_id = ? and company_id = ?").run(id, companyId); - // Clear reports-to references this.db.prepare("update agents set reports_to = null where reports_to = ? and company_id = ?").run(id, companyId); - // Delete the agent + // Clean up orphaned approvals referencing this agent + this.db.prepare("update approvals set requested_by_agent_id = null where requested_by_agent_id = ? and company_id = ?").run(id, companyId); + this.db.prepare("update approvals set related_agent_id = null where related_agent_id = ? and company_id = ?").run(id, companyId); + // Clean up meetings organized by this agent + this.db.prepare("update meetings set organizer_agent_id = null where organizer_agent_id = ? and company_id = ?").run(id, companyId); + // Clean up documents authored/reviewed by this agent + this.db.prepare("update documents set author_agent_id = null where author_agent_id = ? and company_id = ?").run(id, companyId); + this.db.prepare("update documents set reviewer_agent_id = null where reviewer_agent_id = ? and company_id = ?").run(id, companyId); + // Clean up knowledge entries authored by this agent + this.db.prepare("update knowledge_base set author_agent_id = null where author_agent_id = ? and company_id = ?").run(id, companyId); + // Remove pending wakes for this agent + this.db.prepare("delete from pending_wakes where agent_id = ? and company_id = ?").run(id, companyId); this.db.prepare("delete from agents where id = ? and company_id = ?").run(id, companyId); }); } deleteTask(id: string, companyId: string) { + const activeTaskRuns = this.readAll<{ id: string; taskId: string; workspaceId: string | null }>( + "select id, task_id as taskId, workspace_id as workspaceId from runs where task_id = ? and company_id = ? and status in ('queued', 'running')", + id, companyId, + ); this.transaction(() => { - // Clear parent references + for (const run of activeTaskRuns) { + this.cancelRunForDeletion(run.id, run.taskId, run.workspaceId, "Task deleted."); + } this.db.prepare("update tasks set parent_id = null where parent_id = ? and company_id = ?").run(id, companyId); - // Delete related approvals this.db.prepare("delete from approvals where related_task_id = ? and company_id = ?").run(id, companyId); - // Delete related comments this.db.prepare("delete from task_comments where task_id = ? and company_id = ?").run(id, companyId); - // Delete the task this.db.prepare("delete from tasks where id = ? and company_id = ?").run(id, companyId); }); } deleteGoal(id: string, companyId: string) { this.transaction(() => { - // Clear parent references this.db.prepare("update goals set parent_id = null where parent_id = ? and company_id = ?").run(id, companyId); - // Clear goal references from projects this.db.prepare("update projects set goal_id = null where goal_id = ? and company_id = ?").run(id, companyId); - // Clear goal references from tasks this.db.prepare("update tasks set goal_id = null where goal_id = ? and company_id = ?").run(id, companyId); - // Delete the goal this.db.prepare("delete from goals where id = ? and company_id = ?").run(id, companyId); }); } deleteProject(id: string, companyId: string) { this.transaction(() => { - // Clear project references from tasks this.db.prepare("update tasks set project_id = null where project_id = ? and company_id = ?").run(id, companyId); - // Clear project references from workspaces this.db.prepare("update workspaces set project_id = null where project_id = ? and company_id = ?").run(id, companyId); - // Delete the project this.db.prepare("delete from projects where id = ? and company_id = ?").run(id, companyId); }); } deleteWorkspace(id: string, companyId: string) { this.transaction(() => { - // Clear workspace references from agents this.db.prepare("update agents set workspace_id = null where workspace_id = ? and company_id = ?").run(id, companyId); - // Clear workspace references from tasks this.db.prepare("update tasks set workspace_id = null where workspace_id = ? and company_id = ?").run(id, companyId); - // Delete the workspace this.db.prepare("delete from workspaces where id = ? and company_id = ?").run(id, companyId); }); } @@ -1201,8 +1290,6 @@ export class AppDatabase { this.db.prepare("delete from secrets where id = ? and company_id = ?").run(id, companyId); } - /* ─── Social Accounts ────────────────────────────────────────────── */ - saveSocialAccount(input: { id?: string | null; companyId: string; @@ -1243,8 +1330,8 @@ export class AppDatabase { } listSocialAccounts(companyId: string): SocialAccountRecord[] { - const rows = this.db.prepare("SELECT * FROM social_accounts WHERE company_id = ? ORDER BY created_at DESC").all(companyId) as any[]; - return rows.map((r: any) => ({ + const rows = this.db.prepare("SELECT * FROM social_accounts WHERE company_id = ? ORDER BY created_at DESC").all(companyId) as SqliteRow[]; + return rows.map((r) => ({ id: r.id, companyId: r.company_id, platform: r.platform, @@ -1261,8 +1348,6 @@ export class AppDatabase { })); } - /* ─── Browser Actions ────────────────────────────────────────────── */ - saveBrowserAction(input: { companyId: string; socialAccountId: string; @@ -1312,8 +1397,8 @@ export class AppDatabase { sql += " ORDER BY created_at DESC"; if (filters?.limit) { sql += " LIMIT ?"; params.push(filters.limit); } - const rows = this.db.prepare(sql).all(...params) as any[]; - return rows.map((r: any) => ({ + const rows = this.db.prepare(sql).all(...params) as SqliteRow[]; + return rows.map((r) => ({ id: r.id, companyId: r.company_id, socialAccountId: r.social_account_id, @@ -1333,19 +1418,95 @@ export class AppDatabase { })); } - getSidebarBadges(companyId: string): { pendingApprovals: number; activeRuns: number; failedRuns: number; todoTasks: number; budgetWarnings: number } { + getBrowserAction(id: string, companyId: string): BrowserActionRecord | null { + const row = this.db + .prepare("SELECT * FROM browser_actions WHERE id = ? AND company_id = ?") + .get(id, companyId) as SqliteRow | undefined; + if (!row) { + return null; + } + return { + id: row.id, + companyId: row.company_id, + socialAccountId: row.social_account_id, + agentId: row.agent_id, + taskId: row.task_id, + status: row.status, + actionType: row.action_type, + payloadJson: row.payload_json, + resultSummary: row.result_summary, + screenshotPath: row.screenshot_path, + approvalId: row.approval_id, + errorMessage: row.error_message, + startedAt: row.started_at, + finishedAt: row.finished_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + getBrowserActionByApprovalId(approvalId: string, companyId: string): BrowserActionRecord | null { + const row = this.db + .prepare("SELECT * FROM browser_actions WHERE approval_id = ? AND company_id = ? ORDER BY created_at DESC LIMIT 1") + .get(approvalId, companyId) as SqliteRow | undefined; + if (!row) { + return null; + } + return { + id: row.id, + companyId: row.company_id, + socialAccountId: row.social_account_id, + agentId: row.agent_id, + taskId: row.task_id, + status: row.status, + actionType: row.action_type, + payloadJson: row.payload_json, + resultSummary: row.result_summary, + screenshotPath: row.screenshot_path, + approvalId: row.approval_id, + errorMessage: row.error_message, + startedAt: row.started_at, + finishedAt: row.finished_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + getSocialAccount(id: string, companyId: string): SocialAccountRecord | null { + const row = this.db + .prepare("SELECT * FROM social_accounts WHERE id = ? AND company_id = ?") + .get(id, companyId) as SqliteRow | undefined; + if (!row) { + return null; + } + return { + id: row.id, + companyId: row.company_id, + platform: row.platform, + accountName: row.account_name, + displayName: row.display_name, + profileUrl: row.profile_url, + credentialSecretId: row.credential_secret_id, + status: row.status, + requireApproval: toBoolean(row.require_approval), + metadataJson: row.metadata_json, + lastUsedAt: row.last_used_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + getSidebarBadges(companyId: string): { pendingApprovals: number; activeRuns: number; failedRuns: number; todoTasks: number; budgetWarnings: number; unreadMessages: number } { const pendingApprovals = (this.db.prepare("select count(*) as c from approvals where company_id = ? and state = 'pending'").get(companyId) as { c: number })?.c ?? 0; const activeRuns = (this.db.prepare("select count(*) as c from runs where company_id = ? and status in ('queued', 'running')").get(companyId) as { c: number })?.c ?? 0; const failedRuns = (this.db.prepare("select count(*) as c from runs where company_id = ? and status in ('failed', 'timed_out', 'interrupted')").get(companyId) as { c: number })?.c ?? 0; const todoTasks = (this.db.prepare("select count(*) as c from tasks where company_id = ? and status in ('todo', 'in_progress', 'in_review')").get(companyId) as { c: number })?.c ?? 0; const budgetWarnings = (this.db.prepare("select count(*) as c from agents where company_id = ? and budget_monthly_usd > 0 and (spent_monthly_usd * 1.0 / budget_monthly_usd) >= 0.8").get(companyId) as { c: number })?.c ?? 0; - return { pendingApprovals, activeRuns, failedRuns, todoTasks, budgetWarnings }; + const unreadMessages = (this.db.prepare("select count(*) as c from agent_messages where company_id = ? and read_at is null and (channel = 'company' or channel = 'incident' or priority = 'urgent')").get(companyId) as { c: number })?.c ?? 0; + return { pendingApprovals, activeRuns, failedRuns, todoTasks, budgetWarnings, unreadMessages }; } - /* ─── Comments ────────────────────────────────────────────────────── */ - addComment(input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }): CommentRecord { - // Validate task belongs to the specified company const taskRow = this.db.prepare("select company_id from tasks where id = ?").get(input.taskId) as { company_id: string } | undefined; if (!taskRow) throw new Error("Task not found."); if (taskRow.company_id !== input.companyId) throw new Error("Task does not belong to this company."); @@ -1364,8 +1525,6 @@ export class AppDatabase { ); } - /* ─── Heartbeat ──────────────────────────────────────────────────── */ - setHeartbeat(agentId: string, enabled: boolean, intervalSec: number) { this.db.prepare( "update agents set heartbeat_enabled = ?, heartbeat_interval_sec = ?, updated_at = ? where id = ?", @@ -1376,6 +1535,20 @@ export class AppDatabase { this.db.prepare("update agents set last_heartbeat_at = ? where id = ?").run(nowIso(), agentId); } + resetMonthlyBudgets(companyId: string) { + this.db.prepare( + "update agents set spent_monthly_usd = 0, updated_at = ? where company_id = ? and spent_monthly_usd > 0", + ).run(nowIso(), companyId); + } + + getLastBudgetResetMonth(companyId: string): string | null { + return this.getSetting(`budget_reset_month_${companyId}`); + } + + setLastBudgetResetMonth(companyId: string, month: string) { + this.setSetting(`budget_reset_month_${companyId}`, month); + } + listHeartbeatEligibleAgents(companyId: string): AgentRecord[] { return this.readAll( "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and heartbeat_enabled = 1 and status not in ('terminated', 'paused', 'error', 'pending_approval')", @@ -1383,8 +1556,6 @@ export class AppDatabase { ).map((record) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[]; } - /* ─── Task Checkout (for agent API) ──────────────────────────────── */ - checkoutTask(taskId: string, agentId: string): boolean { const result = this.db .prepare( @@ -1438,8 +1609,6 @@ export class AppDatabase { ); } - /* ─── Org helpers for agent API ──────────────────────────────────── */ - getChainOfCommand(agentId: string): AgentRecord[] { const chain: AgentRecord[] = []; const visited = new Set(); @@ -1465,24 +1634,35 @@ export class AppDatabase { ).map((record) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[]; } - /* ─── Deferred Wake Queue (Step 3) ─────────────────────────────── */ - enqueuePendingWake(companyId: string, agentId: string, trigger: string) { + const existing = this.db + .prepare("select id from pending_wakes where company_id = ? and agent_id = ? limit 1") + .get(companyId, agentId) as { id: string } | undefined; + const ts = nowIso(); + if (existing) { + this.db + .prepare("update pending_wakes set trigger_reason = ?, created_at = ? where id = ?") + .run(trigger, ts, existing.id); + return; + } this.db .prepare( "insert into pending_wakes (id, company_id, agent_id, trigger_reason, created_at) values (?, ?, ?, ?, ?)", ) - .run(randomUUID(), companyId, agentId, trigger, nowIso()); + .run(randomUUID(), companyId, agentId, trigger, ts); } consumePendingWakes(agentId: string): Array<{ id: string; companyId: string; trigger: string }> { - const rows = this.readAll<{ id: string; companyId: string; trigger: string }>( - "select id, company_id as companyId, trigger_reason as trigger from pending_wakes where agent_id = ? order by created_at asc", - agentId, - ); - if (rows.length > 0) { - this.db.prepare("delete from pending_wakes where agent_id = ?").run(agentId); - } + let rows: Array<{ id: string; companyId: string; trigger: string }> = []; + this.transaction(() => { + rows = this.readAll<{ id: string; companyId: string; trigger: string }>( + "select id, company_id as companyId, trigger_reason as trigger from pending_wakes where agent_id = ? order by created_at asc", + agentId, + ); + if (rows.length > 0) { + this.db.prepare("delete from pending_wakes where agent_id = ?").run(agentId); + } + }); return rows; } @@ -1598,6 +1778,12 @@ export class AppDatabase { } } + private purgeExpiredPendingWakes() { + // Remove pending wakes older than 2 hours — if agent hasn't woken by then, the wake is stale + const cutoff = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + this.db.prepare("delete from pending_wakes where created_at < ?").run(cutoff); + } + private interruptStaleRuns() { const ts = nowIso(); this.transaction(() => { @@ -1607,7 +1793,6 @@ export class AppDatabase { ) .run(ts, ts); this.db.prepare("update tasks set active_run_id = null where active_run_id is not null").run(); - // Release all workspace locks that were held by interrupted runs this.releaseAllWorkspaceLocks(); }); } @@ -1647,35 +1832,16 @@ export class AppDatabase { } private ensureOptionalColumns() { - try { - this.db.prepare("alter table connectors add column updated_at text").run(); - } catch { - // Column already exists. - } - try { - this.db.prepare("alter table approvals add column payload_json text").run(); - } catch { - // Column already exists. - } - // Heartbeat columns on agents - for (const col of [ - "alter table agents add column heartbeat_enabled integer not null default 0", - "alter table agents add column heartbeat_interval_sec integer not null default 120", - "alter table agents add column last_heartbeat_at text", - ]) { - try { - this.db.prepare(col).run(); - } catch { - // Column already exists. - } - } - // Auto-approve column on companies - try { - this.db.prepare("alter table companies add column auto_approve_hires integer not null default 0").run(); - } catch { - // Column already exists. - } - // Comments table + const addColumnIfMissing = (sql: string) => { + try { this.db.prepare(sql).run(); } catch { /* column already exists */ } + }; + + addColumnIfMissing("alter table connectors add column updated_at text"); + addColumnIfMissing("alter table approvals add column payload_json text"); + addColumnIfMissing("alter table agents add column heartbeat_enabled integer not null default 0"); + addColumnIfMissing("alter table agents add column heartbeat_interval_sec integer not null default 120"); + addColumnIfMissing("alter table agents add column last_heartbeat_at text"); + addColumnIfMissing("alter table companies add column auto_approve_hires integer not null default 0"); this.db.exec(` create table if not exists task_comments ( id text primary key, @@ -1690,7 +1856,6 @@ export class AppDatabase { create index if not exists idx_comments_company on task_comments(company_id); `); - // Social media accounts table this.db.exec(` create table if not exists social_accounts ( id text primary key, @@ -1710,7 +1875,6 @@ export class AppDatabase { create index if not exists idx_social_accounts_company on social_accounts(company_id); `); - // Browser actions table this.db.exec(` create table if not exists browser_actions ( id text primary key, @@ -1733,6 +1897,16 @@ export class AppDatabase { create index if not exists idx_browser_actions_company on browser_actions(company_id); create index if not exists idx_browser_actions_account on browser_actions(social_account_id); `); + + // Add composite indexes for tables created in migrations (safe: uses IF NOT EXISTS) + try { + this.db.exec(` + create index if not exists idx_messages_company on agent_messages(company_id); + create index if not exists idx_messages_channel on agent_messages(channel, channel_target_id); + create index if not exists idx_automation_rules_company_status on automation_rules(company_id, status); + create index if not exists idx_automation_log_company on automation_log(company_id); + `); + } catch { /* tables may not exist yet - indexes will be created by migrations */ } } /** @@ -1743,37 +1917,34 @@ export class AppDatabase { * instead of fragile payload_json LIKE matching. */ getBlockingApproval(companyId: string, agentId: string, taskId: string | null): ApprovalRecord | null { + const taskCondition = taskId != null + ? "(related_task_id = ? or requested_by_agent_id = ?)" + : "requested_by_agent_id = ?"; + const taskParams: SQLInputValue[] = taskId != null ? [taskId, agentId] : [agentId]; const rows = this.readAll( `select id, company_id as companyId, related_task_id as relatedTaskId, - requested_by_agent_id as requestedByAgentId, type, - payload_summary as payloadSummary, impact_summary as impactSummary, - decision_note as decisionNote, state, + requested_by_agent_id as requestedByAgentId, related_agent_id as relatedAgentId, + type, payload_summary as payloadSummary, impact_summary as impactSummary, + payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals where company_id = ? and state = 'pending' and ( (type = 'hire_agent' and related_agent_id = ?) - or (type = 'dangerous_command' and (related_task_id = ? or requested_by_agent_id = ?)) - or (type = 'secret_access' and (related_task_id = ? or requested_by_agent_id = ?)) - or (type = 'approve_ceo_strategy' and (related_task_id = ? or requested_by_agent_id = ?)) + or (type = 'dangerous_command' and ${taskCondition}) + or (type = 'secret_access' and ${taskCondition}) + or (type = 'approve_ceo_strategy' and ${taskCondition}) ) order by created_at asc limit 1`, companyId, agentId, - taskId ?? "", - agentId, - taskId ?? "", - agentId, - taskId ?? "", - agentId, + ...taskParams, + ...taskParams, + ...taskParams, ); return rows.length > 0 ? rows[0] : null; } - /** - * Check if an agent is pending hire approval (status = pending_approval). - * Runs must not start for agents that haven't been approved yet. - */ isAgentPendingApproval(agentId: string): boolean { const row = this.db .prepare("select status from agents where id = ?") @@ -1781,26 +1952,6 @@ export class AppDatabase { return row?.status === "pending_approval"; } - /** - * Check if an agent's budget is exhausted (would trigger hard stop). - */ - isAgentBudgetExhausted(agentId: string): boolean { - const row = this.db - .prepare("select budget_monthly_usd as budget, spent_monthly_usd as spent from agents where id = ?") - .get(agentId) as { budget: number; spent: number } | undefined; - if (!row || row.budget <= 0) return false; - return row.spent >= row.budget; - } - - /** - * Update workspace state (ready/busy/blocked/missing/error). - */ - setWorkspaceState(workspaceId: string, state: WorkspaceRecord["state"]) { - this.db - .prepare("update workspaces set state = ?, updated_at = ? where id = ?") - .run(state, nowIso(), workspaceId); - } - /** * Acquire a workspace write lock for a run. Returns true if acquired. * If another run already holds the lock, returns false. @@ -1818,9 +1969,31 @@ export class AppDatabase { return result.changes > 0; } - /** - * Release a workspace write lock. Only the holding run can release it. - */ + claimWorkspaceLock(workspaceId: string, runId: string): boolean { + const holder = this.db + .prepare("select lock_holder_run_id as lockHolderRunId from workspaces where id = ?") + .get(workspaceId) as { lockHolderRunId: string | null } | undefined; + if (!holder) return false; + if (!holder.lockHolderRunId || holder.lockHolderRunId === runId) { + return this.acquireWorkspaceLock(workspaceId, runId); + } + const blockingRun = this.db + .prepare("select status from runs where id = ?") + .get(holder.lockHolderRunId) as { status: RunStatus } | undefined; + if (blockingRun && (blockingRun.status === "queued" || blockingRun.status === "running")) { + return false; + } + const ts = nowIso(); + const result = this.db + .prepare( + `update workspaces + set lock_holder_run_id = ?, lock_acquired_at = ?, state = 'busy', updated_at = ? + where id = ? and lock_holder_run_id = ?`, + ) + .run(runId, ts, ts, workspaceId, holder.lockHolderRunId); + return result.changes > 0; + } + releaseWorkspaceLock(workspaceId: string, runId: string) { this.db .prepare( @@ -1842,26 +2015,30 @@ export class AppDatabase { .run(nowIso()); } - /** - * Persist a run to the deferred queue so it survives worker restarts. - */ - enqueueDeferredRun(runId: string) { - this.db - .prepare("update runs set status = 'queued', updated_at = ? where id = ? and status = 'queued'") - .run(nowIso(), runId); + releaseStaleWorkspaceLocks(): number { + const result = this.db + .prepare( + `update workspaces + set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ? + where lock_holder_run_id is not null + and not exists ( + select 1 + from runs + where runs.id = workspaces.lock_holder_run_id + and runs.status in ('queued', 'running') + )`, + ) + .run(nowIso()); + return Number(result.changes); } - /** - * List runs that were queued but never finished (for crash recovery re-enqueue). - */ - listRecoverableQueuedRuns(): Array<{ id: string; taskId: string; agentId: string; companyId: string; workspaceId: string | null; connectorId: ConnectorId }> { - return this.readAll<{ id: string; taskId: string; agentId: string; companyId: string; workspaceId: string | null; connectorId: ConnectorId }>( - "select id, task_id as taskId, agent_id as agentId, company_id as companyId, workspace_id as workspaceId, connector_id as connectorId from runs where status = 'queued' order by created_at asc", - ); + isWorkspaceLocked(workspaceId: string): boolean { + const row = this.db + .prepare("select lock_holder_run_id from workspaces where id = ?") + .get(workspaceId) as { lock_holder_run_id: string | null } | undefined; + return Boolean(row?.lock_holder_run_id); } - /* ─── Meetings CRUD ────────────────────────────────────────────── */ - saveMeeting(input: { id?: string | null; companyId: string; type: string; title: string; organizerAgentId?: string | null; participantAgentIds?: string; scheduledAt: string; durationMinutes?: number; @@ -1887,8 +2064,6 @@ export class AppDatabase { this.db.prepare("delete from meetings where id = ? and company_id = ?").run(id, companyId); } - /* ─── Documents CRUD ──────────────────────────────────────────── */ - saveDocument(input: { id?: string | null; companyId: string; type: string; title: string; content?: string; authorAgentId?: string | null; reviewerAgentId?: string | null; projectId?: string | null; @@ -1913,8 +2088,6 @@ export class AppDatabase { this.db.prepare("delete from documents where id = ? and company_id = ?").run(id, companyId); } - /* ─── Knowledge Base CRUD ─────────────────────────────────────── */ - saveKnowledgeEntry(input: { id?: string | null; companyId: string; category: string; topic: string; content: string; authorAgentId?: string | null; importance?: string; tagsJson?: string; @@ -1939,8 +2112,6 @@ export class AppDatabase { this.db.prepare("delete from knowledge_base where id = ? and company_id = ?").run(id, companyId); } - /* ─── Sprints CRUD ────────────────────────────────────────────── */ - saveSprint(input: { id?: string | null; companyId: string; name: string; goal?: string; startDate: string; endDate: string; status?: string; retrospectiveNotes?: string; @@ -1965,8 +2136,6 @@ export class AppDatabase { this.db.prepare("delete from sprints where id = ? and company_id = ?").run(id, companyId); } - // ── Automation Rules ────────────────────────────────────────────── - saveAutomationRule(input: { id?: string | null; companyId: string; name: string; description?: string; trigger: string; conditionsJson?: string; action: string; actionConfigJson?: string; @@ -2010,36 +2179,126 @@ export class AppDatabase { ); } - // ── Agent Messages ────────────────────────────────────────────── - sendAgentMessage(input: { companyId: string; fromAgentId: string; toAgentId?: string | null; channel?: string; channelTargetId?: string | null; subject: string; body: string; priority?: string; parentMessageId?: string | null; attachmentsJson?: string; }) { + const channel = input.channel ?? "direct"; + if (channel === "direct" && !input.toAgentId) { + throw new Error("Direct messages require a recipient."); + } const id = randomUUID(); this.db.prepare( "insert into agent_messages (id, company_id, from_agent_id, to_agent_id, channel, channel_target_id, subject, body, priority, parent_message_id, attachments_json, created_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ).run(id, input.companyId, input.fromAgentId, input.toAgentId ?? null, input.channel ?? "direct", input.channelTargetId ?? null, input.subject, input.body, input.priority ?? "normal", input.parentMessageId ?? null, input.attachmentsJson ?? "[]", nowIso()); + ).run(id, input.companyId, input.fromAgentId, input.toAgentId ?? null, channel, input.channelTargetId ?? null, input.subject, input.body, input.priority ?? "normal", input.parentMessageId ?? null, input.attachmentsJson ?? "[]", nowIso()); this.addActivity({ companyId: input.companyId, actor: input.fromAgentId, action: "sent_message", entityType: "agent_message", entityId: id, detail: `Message: ${input.subject}` }); return id; } - listAgentMessages(companyId: string, opts?: { agentId?: string; channel?: string; unreadOnly?: boolean; limit?: number }) { + getAgentMessage(messageId: string, companyId: string): AgentMessageRecord | null { + const row = this.db.prepare( + "select id, company_id as companyId, from_agent_id as fromAgentId, to_agent_id as toAgentId, channel, channel_target_id as channelTargetId, subject, body, priority, read_at as readAt, parent_message_id as parentMessageId, attachments_json as attachmentsJson, created_at as createdAt from agent_messages where id = ? and company_id = ?", + ).get(messageId, companyId) as AgentMessageRecord | undefined; + return row ?? null; + } + + listAgentMessages(companyId: string, opts?: { agentId?: string; channel?: string; channelTargetId?: string | null; unreadOnly?: boolean; limit?: number }) { let sql = "select id, company_id as companyId, from_agent_id as fromAgentId, to_agent_id as toAgentId, channel, channel_target_id as channelTargetId, subject, body, priority, read_at as readAt, parent_message_id as parentMessageId, attachments_json as attachmentsJson, created_at as createdAt from agent_messages where company_id = ?"; const params: SQLInputValue[] = [companyId]; - if (opts?.agentId) { sql += " and (to_agent_id = ? or from_agent_id = ? or to_agent_id is null)"; params.push(opts.agentId, opts.agentId); } if (opts?.channel) { sql += " and channel = ?"; params.push(opts.channel); } - if (opts?.unreadOnly) { sql += " and read_at is null"; } - sql += ` order by created_at desc limit ${opts?.limit ?? 50}`; - return this.db.prepare(sql).all(...params) as any[]; + if (opts?.channelTargetId !== undefined && opts?.channelTargetId !== null) { + sql += " and channel_target_id = ?"; params.push(opts.channelTargetId); + } + sql += " order by created_at asc"; + + let messages = this.readAll(sql, ...params); + if (opts?.agentId) { + let department: string | null = null; + try { + const agent = this.getAgent(opts.agentId); + if (agent.companyId === companyId) { + department = agent.department; + } + } catch { + department = null; + } + + const relevantProjectIds = new Set(); + for (const row of this.readAll<{ projectId: string | null }>( + "select distinct project_id as projectId from tasks where company_id = ? and assignee_agent_id = ? and project_id is not null", + companyId, + opts.agentId, + )) { + if (row.projectId) relevantProjectIds.add(row.projectId); + } + for (const row of this.readAll<{ id: string }>( + "select id from projects where company_id = ? and lead_agent_id = ?", + companyId, + opts.agentId, + )) { + relevantProjectIds.add(row.id); + } + + const readReceiptMap = new Map( + this.readAll<{ messageId: string; readAt: string }>( + `select reads.message_id as messageId, reads.read_at as readAt + from agent_message_reads reads + join agent_messages messages on messages.id = reads.message_id + where reads.agent_id = ? and messages.company_id = ?`, + opts.agentId, + companyId, + ).map((row) => [row.messageId, row.readAt]), + ); + + messages = messages.filter((message) => { + if (message.fromAgentId === opts.agentId) { + return true; + } + if (message.channel === "direct") { + return message.toAgentId === opts.agentId; + } + if (message.channel === "company" || message.channel === "incident") { + return true; + } + if (message.channel === "department") { + return Boolean(department) && message.channelTargetId === department; + } + if (message.channel === "project") { + return Boolean(message.channelTargetId && relevantProjectIds.has(message.channelTargetId)); + } + return false; + }).map((message) => { + if (message.fromAgentId === opts.agentId) { + return { ...message, readAt: message.createdAt }; + } + return { + ...message, + readAt: readReceiptMap.get(message.id) ?? null, + }; + }); + } + if (opts?.unreadOnly) { + messages = messages.filter((message) => !message.readAt); + } + const limit = opts?.limit ?? 200; + return limit > 0 ? messages.slice(-limit) : messages; } markMessageRead(messageId: string, companyId: string) { this.db.prepare("update agent_messages set read_at = ? where id = ? and company_id = ?").run(nowIso(), messageId, companyId); } - // ── Workflow Pipelines ──────────────────────────────────────────── + markAgentMessageRead(messageId: string, companyId: string, agentId: string) { + const ts = nowIso(); + this.db.prepare( + `insert into agent_message_reads (message_id, agent_id, read_at) + select id, ?, ? + from agent_messages + where id = ? and company_id = ? + on conflict(message_id, agent_id) do update set read_at = excluded.read_at`, + ).run(agentId, ts, messageId, companyId); + } saveWorkflow(input: { id?: string | null; companyId: string; name: string; description?: string; @@ -2061,10 +2320,96 @@ export class AppDatabase { return id; } + getWorkflowRecord(id: string, companyId: string): WorkflowPipelineRecord | null { + const row = this.db.prepare( + "select id, company_id as companyId, name, description, steps_json as stepsJson, trigger_type as triggerType, trigger_config_json as triggerConfigJson, status, current_step_index as currentStepIndex, run_count as runCount, last_run_at as lastRunAt, created_at as createdAt, updated_at as updatedAt from workflow_pipelines where id = ? and company_id = ?", + ).get(id, companyId) as WorkflowPipelineRecord | undefined; + return row ?? null; + } + + updateWorkflowExecution(input: { + id: string; + companyId: string; + status: string; + currentStepIndex: number; + stepsJson: string; + incrementRunCount?: boolean; + lastRunAt?: string | null; + }) { + const ts = nowIso(); + const runCountClause = input.incrementRunCount ? ", run_count = run_count + 1" : ""; + const lastRunAt = input.lastRunAt ?? null; + this.db.prepare( + `update workflow_pipelines + set status = ?, current_step_index = ?, steps_json = ?, last_run_at = ?, updated_at = ?${runCountClause} + where id = ? and company_id = ?`, + ).run(input.status, input.currentStepIndex, input.stepsJson, lastRunAt, ts, input.id, input.companyId); + } + deleteWorkflow(id: string, companyId: string) { this.db.prepare("delete from workflow_pipelines where id = ? and company_id = ?").run(id, companyId); } + listActiveAutomationRules(companyId: string, trigger: string): Array<{ + id: string; name: string; trigger: string; conditionsJson: string; + action: string; actionConfigJson: string; sourceDepartment: string | null; + targetDepartment: string | null; priority: number; + }> { + return this.readAll( + "select id, name, trigger, conditions_json as conditionsJson, action, action_config_json as actionConfigJson, source_department as sourceDepartment, target_department as targetDepartment, priority from automation_rules where company_id = ? and trigger = ? and status = 'active' order by priority desc", + companyId, trigger, + ); + } + + advanceTaskStatus(taskId: string, newStatus: string, comment?: string): { previousStatus: string } { + const task = this.db.prepare("select status, company_id from tasks where id = ?").get(taskId) as { status: string; company_id: string } | undefined; + if (!task) throw new Error(`Task not found: ${taskId}`); + const previousStatus = task.status; + if (previousStatus === newStatus) return { previousStatus }; + const ts = nowIso(); + this.db.prepare("update tasks set status = ?, updated_at = ? where id = ?").run(newStatus, ts, taskId); + if (comment) { + this.addComment({ + companyId: task.company_id, + taskId, + authorAgentId: null, + authorName: "System", + body: comment, + }); + } + // Track sprint velocity: increment completed_points when task moves to "done" + if (newStatus === "done" && previousStatus !== "done") { + this.incrementActiveSprintVelocity(task.company_id, 1); + } + // Decrement if task moves back from done + if (previousStatus === "done" && newStatus !== "done") { + this.incrementActiveSprintVelocity(task.company_id, -1); + } + return { previousStatus }; + } + + private incrementActiveSprintVelocity(companyId: string, delta: number) { + const ts = nowIso(); + const today = ts.slice(0, 10); + this.db.prepare( + "update sprints set completed_points = max(0, completed_points + ?), updated_at = ? where company_id = ? and status = 'active' and start_date <= ? and end_date >= ?", + ).run(delta, ts, companyId, today, today); + } + + getIdleAgentsForDepartment(companyId: string, department: string): Array<{ id: string; name: string }> { + return this.readAll( + "select id, name from agents where company_id = ? and department = ? and status in ('idle', 'active') order by spent_monthly_usd asc", + companyId, department, + ); + } + + getManagerForAgent(agentId: string): { id: string; name: string; companyId: string } | null { + const agent = this.db.prepare("select reports_to, company_id from agents where id = ?").get(agentId) as { reports_to: string | null; company_id: string } | undefined; + if (!agent || !agent.reports_to) return null; + const manager = this.db.prepare("select id, name from agents where id = ?").get(agent.reports_to) as { id: string; name: string } | undefined; + return manager ? { ...manager, companyId: agent.company_id } : null; + } + activateWorkflow(id: string, companyId: string) { const row = this.db.prepare("select name, status from workflow_pipelines where id = ? and company_id = ?").get(id, companyId) as { name: string; status: string } | undefined; if (!row) throw new Error(`Workflow not found: ${id}`); @@ -2076,67 +2421,93 @@ export class AppDatabase { this.addActivity({ companyId, actor: "system", action: "started", entityType: "workflow", entityId: id, detail: `Started workflow: ${row.name}` }); } - // ── Auto Task Assignment ────────────────────────────────────────── + listTriggerableWorkflows(companyId: string, triggerType: string): WorkflowPipelineRecord[] { + return this.readAll( + "select id, company_id as companyId, name, description, steps_json as stepsJson, trigger_type as triggerType, trigger_config_json as triggerConfigJson, status, current_step_index as currentStepIndex, run_count as runCount, last_run_at as lastRunAt, created_at as createdAt, updated_at as updatedAt from workflow_pipelines where company_id = ? and trigger_type = ? and status = 'active' order by created_at asc", + companyId, + triggerType, + ) as WorkflowPipelineRecord[]; + } autoAssignTask(taskId: string, companyId: string): string | null { const task = this.db.prepare( - "select id, title, description, project_id, assignee_agent_id from tasks where id = ? and company_id = ?", - ).get(taskId, companyId) as { id: string; title: string; description: string; project_id: string | null; assignee_agent_id: string | null } | undefined; + "select id, title, description, project_id as projectId, assignee_agent_id as assigneeAgentId from tasks where id = ? and company_id = ?", + ).get(taskId, companyId) as { id: string; title: string; description: string; projectId: string | null; assigneeAgentId: string | null } | undefined; if (!task) return null; - if (task.assignee_agent_id) return task.assignee_agent_id; - // Find available agents (active/idle with remaining budget, exclude managers with direct reports) - const agents = this.readAll<{ id: string; department: string | null; capabilities: string; budget_monthly_usd: number; spent_monthly_usd: number }>( - "select id, department, capabilities, budget_monthly_usd, spent_monthly_usd from agents where company_id = ? and status in ('idle', 'active') and budget_monthly_usd > spent_monthly_usd", + // If task already has an assignee, return them (don't reassign) + if (task.assigneeAgentId) return task.assigneeAgentId; + + // Get eligible agents: active/idle with remaining budget + const candidates = this.readAll<{ id: string; name: string; department: string | null; capabilities: string; taskCount: number; budgetMonthlyUsd: number; spentMonthlyUsd: number }>( + `select a.id, a.name, a.department, a.capabilities, + a.budget_monthly_usd as budgetMonthlyUsd, a.spent_monthly_usd as spentMonthlyUsd, + (select count(*) from tasks t where t.assignee_agent_id = a.id and t.status in ('todo', 'in_progress')) as taskCount + from agents a + where a.company_id = ? and a.status in ('idle', 'active') + order by taskCount asc`, companyId, ); - if (agents.length === 0) return null; - // Extract keywords from task title + description for capability matching - const taskText = `${task.title} ${task.description}`.toLowerCase(); - const taskKeywords = taskText.split(/\W+/).filter(w => w.length > 3); + // Filter out agents who have exhausted their budget (budget=0 means unlimited) + const eligible = candidates.filter((a) => { + if (a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd >= a.budgetMonthlyUsd) return false; + return true; + }); - let bestAgent: typeof agents[0] | null = null; + if (eligible.length === 0) return null; + + // Score candidates by capability match, department relevance, project context, and workload + const words = `${task.title} ${task.description}`.toLowerCase().split(/\W+/).filter(w => w.length > 3); + + // If task belongs to a project, prefer agents who lead that project or its department + let projectDept: string | null = null; + let projectLeadId: string | null = null; + if (task.projectId) { + const project = this.db.prepare("select lead_agent_id, goal_id from projects where id = ? and company_id = ?").get(task.projectId, companyId) as { lead_agent_id: string | null; goal_id: string | null } | undefined; + projectLeadId = project?.lead_agent_id ?? null; + if (projectLeadId) { + const lead = this.db.prepare("select department from agents where id = ? and company_id = ?").get(projectLeadId, companyId) as { department: string | null } | undefined; + projectDept = lead?.department ?? null; + } + } + + let bestAgent: string | null = null; let bestScore = -Infinity; - for (const agent of agents) { - // Workload: fewer active tasks = higher score - const activeTasks = this.readAll<{ cnt: number }>( - "select count(*) as cnt from tasks where assignee_agent_id = ? and status in ('todo', 'in_progress')", - agent.id, - )[0]?.cnt ?? 0; - - // Budget: more remaining = higher score - const budgetRemaining = agent.budget_monthly_usd - agent.spent_monthly_usd; - - // Capability match: count overlapping keywords between task and agent capabilities - const capText = (agent.capabilities ?? "").toLowerCase(); - let capMatchCount = 0; - for (const kw of taskKeywords) { - if (capText.includes(kw)) capMatchCount++; - } - - // Composite score: capability match (strongest signal), then workload, then budget - const score = (capMatchCount * 100) + budgetRemaining - (activeTasks * 50); - - if (score > bestScore) { + for (const agent of eligible) { + const capWords = agent.capabilities.toLowerCase().split(/\W+/).filter(w => w.length > 3); + const overlap = words.filter(w => capWords.includes(w)).length; + let score = (overlap * 10) - (agent.taskCount * 5); + // Bonus for same department as project lead + if (projectDept && agent.department === projectDept) score += 15; + // Bonus for agents who are not managers (ICs should do the work) + const hasReports = this.readAll<{ id: string }>("select id from agents where reports_to = ? limit 1", agent.id); + if (hasReports.length === 0) score += 5; + if (score > bestScore || (score === bestScore && agent.taskCount < (eligible.find(c => c.id === bestAgent)?.taskCount ?? 999))) { bestScore = score; - bestAgent = agent; + bestAgent = agent.id; } } - if (bestAgent) { - this.db.prepare("update tasks set assignee_agent_id = ?, updated_at = ? where id = ?").run(bestAgent.id, nowIso(), taskId); - this.addActivity({ companyId, actor: "system", action: "auto_assigned", entityType: "task", entityId: taskId, detail: `Auto-assigned to ${bestAgent.id} (score: ${bestScore})` }); - return bestAgent.id; + // Fallback: pick least loaded agent + if (!bestAgent && eligible.length > 0) { + bestAgent = eligible[0].id; // Already sorted by taskCount asc } - return null; + + this.db.prepare("update tasks set assignee_agent_id = ?, updated_at = ? where id = ? and company_id = ?").run(bestAgent, nowIso(), taskId, companyId); + this.addActivity({ + companyId, + actor: "system", + action: "task.auto_assigned", + entityType: "task", + entityId: taskId, + detail: `Auto-assigned to ${eligible.find(c => c.id === bestAgent)?.name ?? bestAgent}`, + }); + return bestAgent; } - /* ─── Forward-Only Migration System ──────────────────────────────── */ - private runMigrations() { - // Create migration tracking table this.db.exec(` create table if not exists schema_migrations ( version integer primary key, @@ -2181,10 +2552,6 @@ export class AppDatabase { alter table connectors add column capability_json text; `, }, - // Migration 5 (convert_usd_to_cents) was removed: the runtime, shared types, - // and renderer all treat monetary values as dollars. Converting stored data to - // cents without updating every consumer would cause 100x display errors. - // Dollar float values are sufficient for a local-first desktop app. { version: 6, name: "add_agent_department", @@ -2359,6 +2726,19 @@ export class AppDatabase { create index if not exists idx_automation_log_company on automation_log(company_id) `, }, + { + version: 12, + name: "add_agent_message_reads", + sql: ` + create table if not exists agent_message_reads ( + message_id text not null, + agent_id text not null, + read_at text not null, + primary key (message_id, agent_id) + ); + create index if not exists idx_agent_message_reads_agent on agent_message_reads(agent_id); + `, + }, ]; for (const migration of migrations) { @@ -2481,7 +2861,6 @@ export class AppDatabase { this.init(); } - // ── Inbox ────────────────────────────────────────────────────────── getInbox(companyId: string): InboxItem[] { const items: InboxItem[] = []; const snapshot = this.listSnapshot(); @@ -2502,7 +2881,6 @@ export class AppDatabase { agentId: approval.requestedByAgentId, agentName: requester?.name ?? null, createdAt: approval.createdAt, - dismissed: false, }); } @@ -2521,7 +2899,6 @@ export class AppDatabase { agentId: run.agentId, agentName: agent?.name ?? null, createdAt: run.createdAt, - dismissed: false, }); } @@ -2539,7 +2916,6 @@ export class AppDatabase { agentId: agent.id, agentName: agent.name, createdAt: agent.updatedAt, - dismissed: false, }); } @@ -2557,7 +2933,6 @@ export class AppDatabase { agentId: task.assigneeAgentId, agentName: assignee?.name ?? null, createdAt: task.updatedAt, - dismissed: false, }); } @@ -2575,17 +2950,35 @@ export class AppDatabase { agentId: task.assigneeAgentId, agentName: assignee?.name ?? null, createdAt: task.updatedAt, - dismissed: false, }); } - // Sort by severity then date + // Unread urgent agent messages (company-wide or incident channels that the Board should see) + for (const msg of snapshot.agentMessages.filter(m => + m.companyId === companyId && + !m.readAt && + (m.channel === "company" || m.channel === "incident" || m.priority === "urgent"), + )) { + const sender = agentMap.get(msg.fromAgentId); + items.push({ + id: `message-${msg.id}`, + type: "agent_message", + title: msg.subject, + subtitle: `${sender?.name ?? "Unknown"} — ${msg.channel === "incident" ? "INCIDENT" : msg.channel} ${msg.priority === "urgent" ? "(urgent)" : ""}`, + severity: msg.priority === "urgent" || msg.channel === "incident" ? "critical" : "medium", + entityType: "message", + entityId: msg.id, + agentId: msg.fromAgentId, + agentName: sender?.name ?? null, + createdAt: msg.createdAt, + }); + } + const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; items.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity] || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); return items; } - // ── Company Metrics ──────────────────────────────────────────────── getCompanyMetrics(companyId: string, days: number = 30): CompanyMetrics { const snapshot = this.listSnapshot(); const agents = snapshot.agents.filter(a => a.companyId === companyId); @@ -2619,10 +3012,11 @@ export class AppDatabase { ? finishedRuns.reduce((sum, r) => sum + (new Date(r.finishedAt!).getTime() - new Date(r.startedAt!).getTime()) / 1000, 0) / finishedRuns.length : 0; - // Review cycles: count tasks that went from in_review back to in_progress - // Approximation: count activity entries with action "task.status_changed" and detail containing "in_progress" + // Review cycles: count task status transitions by this agent + // Actual action strings: "task.status_changed" (api-server), "task.updated" (index.ts), "task.auto_assigned" (database) const reviewCycles = snapshot.activity.filter( - a => a.companyId === companyId && a.actor === agent.id && a.action === "task.status_changed" + a => a.companyId === companyId && a.actor === agent.id + && (a.action === "task.status_changed" || a.action === "task.updated" || a.action === "task.auto_assigned") ).length; const lastRun = agentRuns.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]; @@ -2661,7 +3055,6 @@ export class AppDatabase { }; } - // ── Standup Report ───────────────────────────────────────────────── getStandupReport(companyId: string): StandupReport { const snapshot = this.listSnapshot(); const agents = snapshot.agents.filter(a => a.companyId === companyId && a.status !== "terminated" && a.status !== "pending_approval"); @@ -2698,6 +3091,19 @@ export class AppDatabase { const totalSpent = agents.reduce((sum, a) => sum + a.spentMonthlyUsd, 0); const budgetUtilization = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0; + // Active sprint progress + const activeSprint = snapshot.sprints.find(s => s.companyId === companyId && s.status === "active"); + if (activeSprint) { + const pct = activeSprint.velocityPoints > 0 ? Math.round((activeSprint.completedPoints / activeSprint.velocityPoints) * 100) : 0; + companyHighlights.push(`Sprint "${activeSprint.name}": ${activeSprint.completedPoints}/${activeSprint.velocityPoints} pts (${pct}%)`); + } + + // Unread messages requiring attention + const urgentUnread = snapshot.agentMessages.filter(m => m.companyId === companyId && !m.readAt && (m.priority === "urgent" || m.channel === "incident")).length; + if (urgentUnread > 0) { + blockers.push(`${urgentUnread} urgent unread message(s) need attention`); + } + const agentReports = agents.map(agent => { const agentTasks = tasks.filter(t => t.assigneeAgentId === agent.id); const agentRuns = runs.filter(r => r.agentId === agent.id); @@ -2709,7 +3115,7 @@ export class AppDatabase { const completedRecently = agentTasks.filter(t => t.status === "done" && t.updatedAt > oneDayAgo); if (completedRecently.length > 0) highlights.push(`Completed ${completedRecently.length} task(s)`); if (agentRecentRuns.length > 0) highlights.push(`${agentRecentRuns.length} run(s) in last 24h`); - const reviewedTasks = recentActivity.filter(a => a.actor === agent.id && a.action.includes("status_changed")).length; + const reviewedTasks = recentActivity.filter(a => a.actor === agent.id && (a.action === "task.status_changed" || a.action === "task.updated" || a.action === "task.auto_assigned")).length; if (reviewedTasks > 0) highlights.push(`${reviewedTasks} status update(s)`); return { diff --git a/src/main/index.ts b/src/main/index.ts index a6206824..d07057fb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,6 +16,7 @@ import { net, session, shell, + Tray, utilityProcess, } from "electron"; import electronUpdater from "electron-updater"; @@ -34,7 +35,6 @@ import { deleteEntitySchema, goalInputSchema, heartbeatSettingsSchema, - inboxDismissSchema, listCommentsSchema, metricsQuerySchema, type DesktopResult, @@ -57,8 +57,11 @@ import { messageQuerySchema, workflowInputSchema, autoAssignSchema, + toggleAutomationRuleSchema, + markMessageReadSchema, + runWorkflowSchema, } from "@shared/contracts"; -import type { DesktopEvent } from "@shared/types"; +import type { DesktopEvent, RunRecord } from "@shared/types"; import { getConnectorDefinition } from "./connectors"; import { AppDatabase } from "./database"; import { AgentApiServer } from "./api-server"; @@ -69,11 +72,8 @@ import { BrowserManager } from "./browser-manager"; app.name = "AgentCompany"; -// ── Fix PATH for packaged macOS/Linux apps ────────────────────────────────── -// Packaged Electron apps on macOS launched from Finder inherit a minimal PATH -// (/usr/bin:/bin:/usr/sbin:/sbin) that doesn't include Homebrew, nvm, cargo, etc. -// We prepend common installation directories so connectors like claude, codex, -// gemini, opencode, kimi can be found. +// Packaged Electron apps inherit a minimal PATH; prepend common tool directories +// so CLI connectors (claude, codex, gemini, etc.) can be found. { const extra = [ "/usr/local/bin", @@ -85,7 +85,6 @@ app.name = "AgentCompany"; `${process.env.HOME}/.volta/bin`, `${process.env.HOME}/.bun/bin`, "/usr/local/sbin", - // Common Linux snap/flatpak paths "/snap/bin", ].filter((p) => p && !p.includes("undefined")); const current = process.env.PATH ?? ""; @@ -123,6 +122,11 @@ let worker: ReturnType | null = null; let apiServer: AgentApiServer | null = null; let browserManager: BrowserManager | null = null; let heartbeatTimer: ReturnType | null = null; +let appTray: Tray | null = null; +const scheduleLastFiredMap = new Map(); +const activeAutomationExecutions = new Set(); +const activeWorkflowTriggerExecutions = new Set(); +// Budget reset tracking moved to database (db.getLastBudgetResetMonth / db.setLastBudgetResetMonth) let isQuitting = false; let backgroundCloseNoticeShown = false; @@ -158,6 +162,13 @@ const workerMessageSchema = z.union([ ]); type CompanyScopedTable = "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets"; +type WorkspaceLockMode = "exclusive" | "shared" | "none"; + +interface WorkspaceResolution { + workspace: ReturnType; + lockMode: WorkspaceLockMode; + source: "task" | "agent" | "project" | "company_fallback" | "none"; +} function ok(data: T): DesktopResult { return { ok: true, data }; @@ -167,6 +178,38 @@ function fail(code: string, message: string): DesktopResult { return { ok: false, error: { code, message } }; } +function hasActiveRun(_snapshot: ReturnType, filter: { taskId?: string; agentId?: string; companyId: string }) { + return db.hasActiveRun(filter); +} + +function resolveWorkspace( + task: { workspaceId: string | null; projectId?: string | null }, + agent: { workspaceId: string | null }, + companyId: string, +): WorkspaceResolution { + const taskWorkspace = db.getWorkspace(task.workspaceId); + if (taskWorkspace) { + return { workspace: taskWorkspace, lockMode: "exclusive", source: "task" }; + } + + const agentWorkspace = db.getWorkspace(agent.workspaceId); + if (agentWorkspace) { + return { workspace: agentWorkspace, lockMode: "exclusive", source: "agent" }; + } + + const projectWorkspace = db.getProjectPrimaryWorkspace(task.projectId ?? null, companyId); + if (projectWorkspace) { + return { workspace: projectWorkspace, lockMode: "exclusive", source: "project" }; + } + + const fallbackWorkspace = db.getAnyCompanyWorkspace(companyId); + if (fallbackWorkspace) { + return { workspace: fallbackWorkspace, lockMode: "shared", source: "company_fallback" }; + } + + return { workspace: null, lockMode: "none", source: "none" }; +} + function isTrustedRendererUrl(url: string) { if (!url) { return false; @@ -226,6 +269,277 @@ function notify(title: string, body: string) { } } +function normalizeWorkflowSteps(raw: unknown) { + if (!Array.isArray(raw)) { + throw new Error("Workflow steps must be a JSON array."); + } + return raw.map((entry, index) => { + const step = (entry ?? {}) as Record; + const action = typeof step.action === "string" ? step.action : "send_message"; + return { + id: typeof step.id === "string" && step.id ? step.id : `step-${index + 1}`, + name: typeof step.name === "string" && step.name ? step.name : `Step ${index + 1}`, + action, + configJson: typeof step.configJson === "string" ? step.configJson : "{}", + assigneeDepartment: typeof step.assigneeDepartment === "string" ? step.assigneeDepartment : null, + assigneeAgentId: typeof step.assigneeAgentId === "string" ? step.assigneeAgentId : null, + requiresApproval: step.requiresApproval === true, + timeoutSec: typeof step.timeoutSec === "number" ? step.timeoutSec : 0, + status: ["pending", "running", "completed", "failed", "skipped"].includes(String(step.status)) ? String(step.status) : "pending", + }; + }); +} + +function resolveWorkflowStepActor(step: ReturnType[number], companyId: string) { + if (step.assigneeAgentId) { + const agent = db.getAgent(step.assigneeAgentId); + if (agent.companyId !== companyId) { + throw new Error(`Workflow step assignee ${step.assigneeAgentId} does not belong to company ${companyId}.`); + } + return agent; + } + + if (step.assigneeDepartment) { + const idleAgents = db.getIdleAgentsForDepartment(companyId, step.assigneeDepartment); + if (idleAgents.length > 0) { + return db.getAgent(idleAgents[0]!.id); + } + } + + const snapshot = db.listSnapshot(); + return snapshot.agents.find((agent) => agent.companyId === companyId && !agent.reportsTo && agent.status !== "terminated") ?? null; +} + +function buildWorkflowActionContext( + companyId: string, + step: ReturnType[number], + actor: ReturnType, +) { + let config: Record = {}; + try { + config = JSON.parse(step.configJson) as Record; + } catch { + throw new Error(`Workflow step "${step.name}" has invalid config JSON.`); + } + + return { + config, + ctx: { + companyId, + agentId: actor?.id, + department: actor?.department ?? step.assigneeDepartment ?? undefined, + taskId: typeof config.taskId === "string" ? config.taskId : undefined, + runId: typeof config.runId === "string" ? config.runId : undefined, + projectId: typeof config.projectId === "string" ? config.projectId : undefined, + newStatus: typeof config.status === "string" ? config.status : undefined, + }, + }; +} + +async function runWorkflowPipeline(workflowId: string, companyId: string) { + const workflow = db.getWorkflowRecord(workflowId, companyId); + if (!workflow) { + throw new Error("Workflow not found."); + } + const previousWorkflowStatus = workflow.status; + const parsedSteps = normalizeWorkflowSteps(JSON.parse(workflow.stepsJson || "[]")); + const hasRunningStep = parsedSteps.some((step) => step.status === "running"); + if (hasRunningStep) { + throw new Error("Workflow is already running."); + } + if (parsedSteps.length === 0) { + throw new Error("Workflow has no steps."); + } + + const resumeFrom = workflow.status === "paused" ? workflow.currentStepIndex : 0; + const steps = parsedSteps.map((step, index) => { + if (workflow.status !== "paused") { + return { ...step, status: index < resumeFrom ? "completed" : "pending" }; + } + return step; + }); + + db.updateWorkflowExecution({ + id: workflowId, + companyId, + status: "active", + currentStepIndex: resumeFrom, + stepsJson: JSON.stringify(steps), + incrementRunCount: true, + lastRunAt: new Date().toISOString(), + }); + publishDomainChanged(); + + for (let index = resumeFrom; index < steps.length; index += 1) { + const step = steps[index]!; + step.status = "running"; + db.updateWorkflowExecution({ + id: workflowId, + companyId, + status: "active", + currentStepIndex: index, + stepsJson: JSON.stringify(steps), + lastRunAt: new Date().toISOString(), + }); + publishDomainChanged(); + + const actor = resolveWorkflowStepActor(step, companyId); + const { config, ctx } = buildWorkflowActionContext(companyId, step, actor); + + if (step.requiresApproval) { + const approvalId = db.requestApproval({ + companyId, + requestedByAgentId: actor?.id ?? null, + relatedTaskId: ctx.taskId ?? null, + type: "dangerous_command", + payloadSummary: `Workflow approval required: ${workflow.name} / ${step.name}`, + impactSummary: `Workflow step "${step.name}" requires board approval before ${step.action}.`, + payloadJson: JSON.stringify({ workflowId, stepId: step.id, stepName: step.name }), + }); + step.status = "pending"; + db.updateWorkflowExecution({ + id: workflowId, + companyId, + status: "paused", + currentStepIndex: index, + stepsJson: JSON.stringify(steps), + lastRunAt: new Date().toISOString(), + }); + db.addActivity({ + companyId, + actor: actor?.id ?? "system", + action: "workflow.paused_for_approval", + entityType: "workflow", + entityId: workflowId, + detail: `Paused on step "${step.name}" with approval ${approvalId}.`, + }); + publishDomainChanged(); + return; + } + + try { + executeAutomationAction( + { + action: step.action, + actionConfigJson: JSON.stringify(config), + targetDepartment: step.assigneeDepartment, + trigger: "workflow_step", + }, + ctx, + ); + step.status = "completed"; + db.addActivity({ + companyId, + actor: actor?.id ?? "system", + action: "workflow.step_completed", + entityType: "workflow", + entityId: workflowId, + detail: step.name, + }); + const completionStatus = index === steps.length - 1 + ? (previousWorkflowStatus === "draft" ? "draft" : "active") + : "active"; + db.updateWorkflowExecution({ + id: workflowId, + companyId, + status: completionStatus, + currentStepIndex: index + 1, + stepsJson: JSON.stringify(steps), + lastRunAt: new Date().toISOString(), + }); + publishDomainChanged(); + } catch (error) { + step.status = "failed"; + db.updateWorkflowExecution({ + id: workflowId, + companyId, + status: "failed", + currentStepIndex: index, + stepsJson: JSON.stringify(steps), + lastRunAt: new Date().toISOString(), + }); + db.addActivity({ + companyId, + actor: actor?.id ?? "system", + action: "workflow.step_failed", + entityType: "workflow", + entityId: workflowId, + detail: `${step.name}: ${error instanceof Error ? error.message : String(error)}`, + }); + publishDomainChanged(); + throw error; + } + } +} + +async function dispatchBrowserAction(actionId: string, companyId: string) { + if (!browserManager) { + throw new Error("Browser manager not initialized"); + } + + const action = db.getBrowserAction(actionId, companyId); + if (!action) { + throw new Error(`Browser action not found: ${actionId}`); + } + + const account = db.getSocialAccount(action.socialAccountId, companyId); + if (!account) { + db.updateBrowserAction(actionId, { + status: "failed", + errorMessage: "Social account no longer exists.", + finishedAt: new Date().toISOString(), + }); + publishDomainChanged(); + return; + } + + let payload: Record; + try { + payload = JSON.parse(action.payloadJson) as Record; + } catch { + db.updateBrowserAction(actionId, { + status: "failed", + errorMessage: "Browser action payload is not valid JSON.", + finishedAt: new Date().toISOString(), + }); + publishDomainChanged(); + return; + } + + db.updateBrowserAction(actionId, { + status: "running", + startedAt: new Date().toISOString(), + errorMessage: null, + }); + publishDomainChanged(); + + try { + const result = await browserManager.executeAction({ + id: actionId, + socialAccountId: account.id, + platform: account.platform, + actionType: action.actionType, + payload, + }); + + db.updateBrowserAction(actionId, { + status: result.success ? "succeeded" : "failed", + resultSummary: result.summary, + screenshotPath: result.screenshotPath, + errorMessage: result.error, + finishedAt: new Date().toISOString(), + }); + } catch (error) { + db.updateBrowserAction(actionId, { + status: "failed", + errorMessage: error instanceof Error ? error.message : String(error), + finishedAt: new Date().toISOString(), + }); + } + + publishDomainChanged(); +} + function buildMenu() { const macAppMenu: Electron.MenuItemConstructorOptions = { label: app.name, @@ -321,10 +635,60 @@ function buildMenu() { Menu.setApplicationMenu(Menu.buildFromTemplate(template)); } +function createTray() { + if (process.platform === "darwin" || appTray) { + return; + } + + const trayIcon = nativeImage.createFromPath(ICON_PATH); + appTray = new Tray(trayIcon.isEmpty() ? nativeImage.createEmpty() : trayIcon); + appTray.setToolTip("AgentCompany"); + appTray.setContextMenu( + Menu.buildFromTemplate([ + { + label: "Show AgentCompany", + click: () => { + if (!mainWindow || mainWindow.isDestroyed()) { + void createWindow(); + return; + } + mainWindow.show(); + mainWindow.focus(); + }, + }, + { + label: "Hide AgentCompany", + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.hide(); + } + }, + }, + { type: "separator" }, + { + label: "Quit", + click: () => { + isQuitting = true; + app.quit(); + }, + }, + ]), + ); + appTray.on("click", () => { + if (!mainWindow || mainWindow.isDestroyed()) { + void createWindow(); + return; + } + if (mainWindow.isVisible()) { + mainWindow.hide(); + return; + } + mainWindow.show(); + mainWindow.focus(); + }); +} + function isSocialBrowserSession(contents: Electron.WebContents): boolean { - // Social login windows use partitioned sessions named "persist:social-..." - const partition = (contents.session as { getStoragePath?: () => string })?.getStoragePath?.() ?? ""; - // Simpler: check the session object reference — social sessions are never the default session return contents.session !== session.defaultSession; } @@ -334,8 +698,7 @@ function installSecurityGuards() { callback(false); }); app.on("web-contents-created", (_event, contents) => { - contents.setWindowOpenHandler(({ url }) => { - // Allow social browser windows to open popups (OAuth flows etc.) + contents.setWindowOpenHandler(() => { if (isSocialBrowserSession(contents)) { return { action: "allow" }; } @@ -345,7 +708,6 @@ function installSecurityGuards() { event.preventDefault(); }); contents.on("will-navigate", (event) => { - // Allow navigation in social login browser windows if (isSocialBrowserSession(contents)) return; event.preventDefault(); }); @@ -416,21 +778,17 @@ function createWorker() { worker.stdout?.on("data", (chunk) => log.info(`[worker] ${String(chunk).trim()}`)); worker.stderr?.on("data", (chunk) => log.error(`[worker] ${String(chunk).trim()}`)); - // Step 7: Worker crash recovery worker.on("exit", (code) => { if (isQuitting) return; log.warn(`[worker] Worker exited with code ${code}, recovering...`); - // Separate running runs (truly executing — mark interrupted) from queued runs - // (never started — eligible for re-enqueue after worker restarts). const snapshot = db.listSnapshot(); const affectedAgentIds = new Set(); const requeueCandidates: Array<{ id: string; taskId: string; agentId: string; companyId: string; workspaceId: string | null; connectorId: string }> = []; for (const run of snapshot.runs) { if (run.status === "running") { - // Running runs were actually executing — mark interrupted db.finishRun({ runId: run.id, status: "interrupted", @@ -453,7 +811,6 @@ function createWorker() { wakeAgentIfPossible(chain[0].id, run.companyId, "report_failed"); } } else if (run.status === "queued") { - // Queued runs never started — collect for re-enqueue requeueCandidates.push({ id: run.id, taskId: run.taskId, @@ -465,18 +822,15 @@ function createWorker() { } } - // Drain deferred wakes for affected agents for (const agentId of affectedAgentIds) { const pendingWakes = db.consumePendingWakes(agentId); - if (pendingWakes.length > 0) { - const wake = pendingWakes[0]; + for (const wake of pendingWakes) { wakeAgentIfPossible(agentId, wake.companyId, wake.trigger); } } publishDomainChanged(); - // Restart the worker, then re-enqueue queued runs that never started createWorker(); if (requeueCandidates.length > 0 && worker) { @@ -486,119 +840,24 @@ function createWorker() { try { const task = db.getTask(candidate.taskId); const agent = db.getAgent(candidate.agentId); - const connector = db.getConnector(agent.connectorId as import("@shared/types").ConnectorId); - const workspace = db.getWorkspace(candidate.workspaceId); - const env = db.resolveSecretEnv(connector.envBindingText, candidate.companyId); - const session = db.getTaskSession(task.id); - - // Build systemPrompt exactly like the normal heartbeat path - const recoveryApiPort = apiServer?.getPort() ?? 0; - const recoveryApiUrl = recoveryApiPort ? `http://127.0.0.1:${recoveryApiPort}` : ""; - const recoveryApiKey = recoveryApiUrl ? createAgentToken(agent.id, candidate.companyId, candidate.id) : ""; - - const recoveryAgentTasks = recoverySnapshot.tasks.filter((t) => t.companyId === candidate.companyId && t.assigneeAgentId === agent.id); + const connector = db.getConnector(agent.connectorId); + const workspaceResolution = resolveWorkspace(task, agent, candidate.companyId); const recoveryCompany = recoverySnapshot.companies.find((c) => c.id === candidate.companyId); - const recoveryRecentComments = recoverySnapshot.comments - .filter((c) => recoveryAgentTasks.some((t) => t.id === c.taskId)) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .slice(0, 20) - .map((c) => ({ - taskId: c.taskId, - taskTitle: recoveryAgentTasks.find((t) => t.id === c.taskId)?.title ?? c.taskId, - authorName: c.authorName, - body: c.body, - createdAt: c.createdAt, - })); - const recoveryPrompt = recoveryApiUrl ? buildTaskPrompt({ - agent, - apiUrl: recoveryApiUrl, - apiKey: recoveryApiKey, + + dispatchRunToWorker({ runId: candidate.id, + companyId: candidate.companyId, + agent, task, + connector, + workspace: workspaceResolution.workspace, + workspaceMode: workspaceResolution.lockMode, companyName: recoveryCompany?.name ?? "Company", - goals: recoverySnapshot.goals.filter((g) => g.companyId === candidate.companyId), - projects: recoverySnapshot.projects.filter((p) => p.companyId === candidate.companyId), - directReports: db.getDirectReports(agent.id, candidate.companyId), - chainOfCommand: db.getChainOfCommand(agent.id), - assignedTasks: db.listAgentTasks(agent.id, candidate.companyId, ["todo", "in_progress", "blocked"]), - allAgents: recoverySnapshot.agents.filter((a) => a.companyId === candidate.companyId), - pendingApprovals: recoverySnapshot.approvals.filter( - (a) => a.companyId === candidate.companyId && - a.requestedByAgentId === agent.id && - (a.state === "pending" || a.state === "revision_requested"), - ), - tasksAwaitingReview: db.listReviewTasksForManager(agent.id, candidate.companyId), - wakeReason: "recovery", - allTasks: recoverySnapshot.tasks.filter((t) => t.companyId === candidate.companyId), - recentComments: recoveryRecentComments, companyDescription: recoveryCompany?.description ?? "", - socialAccounts: db.listSocialAccounts(candidate.companyId).map(a => ({ - id: a.id, - platform: a.platform, - accountName: a.accountName, - status: a.status, - requireApproval: a.requireApproval, - })), - recentMessages: db.listAgentMessages(candidate.companyId, { agentId: agent.id, limit: 15 }), - upcomingMeetings: recoverySnapshot.meetings - .filter((m: any) => m.companyId === candidate.companyId && (m.status === "scheduled" || m.status === "in_progress")) - .filter((m: any) => { - try { const ids = JSON.parse(m.participantAgentIds); return ids.includes(agent.id) || m.organizerAgentId === agent.id; } catch { return false; } - }) - .slice(0, 5), - relevantKnowledge: recoverySnapshot.knowledgeBase - .filter((k: any) => k.companyId === candidate.companyId && (k.importance === "critical" || k.importance === "high")) - .slice(0, 8), - activeSprint: recoverySnapshot.sprints.find((s: any) => s.companyId === candidate.companyId && s.status === "active") ?? null, - recentDocuments: recoverySnapshot.documents - .filter((d: any) => d.companyId === candidate.companyId && (d.authorAgentId === agent.id || d.reviewerAgentId === agent.id || d.status === "in_review")) - .slice(0, 5), - }) : null; - - // Inject API credentials exactly like the normal path - if (recoveryApiUrl) { - env.AGENT_COMPANY_API_URL = recoveryApiUrl; - env.AGENT_COMPANY_API_KEY = recoveryApiKey; - env.AGENT_COMPANY_AGENT_ID = agent.id; - env.AGENT_COMPANY_COMPANY_ID = candidate.companyId; - env.AGENT_COMPANY_RUN_ID = candidate.id; - env.AGENT_COMPANY_TASK_ID = task.id; - env.AGENT_COMPANY_WAKE_REASON = "recovery"; - } - - // Parse thinkingEffort exactly like the normal path - let recoveryThinkingEffort: string | null = null; - try { - const meta = JSON.parse(agent.metadataJson || "{}"); - if (typeof meta.thinkingEffort === "string" && meta.thinkingEffort) { - recoveryThinkingEffort = meta.thinkingEffort; - } - } catch { /* ignore malformed json */ } - - worker.postMessage({ - type: "enqueue-run", - payload: { - runId: candidate.id, - companyId: candidate.companyId, - taskId: task.id, - taskTitle: task.title, - taskDescription: task.description, - agentId: agent.id, - agentName: agent.name, - connectorId: agent.connectorId, - workspaceId: workspace?.id ?? null, - workspacePath: workspace?.localPath ?? null, - command: connector.command, - model: connector.model ?? agent.model, - thinkingEffort: recoveryThinkingEffort, - env, - sessionParams: session.sessionParams, - systemPrompt: recoveryPrompt, - trigger: "recovery", - }, + snapshot: recoverySnapshot, + trigger: "recovery", }); } catch (error) { - // Re-enqueue failed — mark interrupted AND release workspace lock log.warn(`[worker] Failed to re-enqueue run ${candidate.id}: ${error instanceof Error ? error.message : String(error)}`); db.finishRun({ runId: candidate.id, @@ -637,6 +896,13 @@ function createWorker() { if (event.type === "run-status") { db.markRunStatus(event.runId, event.status, event.message); + if (event.status === "running") { + const run = db.listSnapshot().runs.find((entry) => entry.id === event.runId); + if (run?.workspaceId && !db.claimWorkspaceLock(run.workspaceId, run.id)) { + log.error(`[workspace] Failed to claim lock for workspace ${run.workspaceId} when run ${run.id} entered running state. Cancelling run.`); + worker?.postMessage({ type: "cancel-run", runId: run.id }); + } + } emitEvent(event); publishDomainChanged(); return; @@ -647,9 +913,8 @@ function createWorker() { if (run) { const connector = getConnectorDefinition(run.connectorId); - // Guard: task/agent may have been deleted while the run was active let task: ReturnType | null = null; - try { task = db.getTask(run.taskId); } catch { /* task deleted mid-run */ } + try { task = db.getTask(run.taskId); } catch { /* deleted mid-run */ } if (task) { if (event.sessionParams && connector.module.sessionCodec) { @@ -662,7 +927,6 @@ function createWorker() { } const runOutcome = db.finishRun(event); - // Release workspace lock now that the run is finished if (run.workspaceId) { db.releaseWorkspaceLock(run.workspaceId, run.id); } @@ -685,15 +949,13 @@ function createWorker() { notify("Run attention required", task.title); } - // Guard: agent may have been deleted while the run was active let updatedAgent: ReturnType | null = null; - try { updatedAgent = db.getAgent(run.agentId); } catch { /* agent deleted mid-run */ } + try { updatedAgent = db.getAgent(run.agentId); } catch { /* deleted mid-run */ } if (updatedAgent && runOutcome.budgetHardStopped) { autoPauseBudgetExceededAgent(updatedAgent); } - // Step 6: Run failure escalation — wake agent's manager via org hierarchy if (event.status === "failed" || event.status === "timed_out") { try { const chain = db.getChainOfCommand(run.agentId); @@ -703,15 +965,14 @@ function createWorker() { } catch { /* agent deleted, skip escalation */ } } - // Step 3c: Consume deferred wakes — re-wake agent for each pending trigger const pendingWakes = db.consumePendingWakes(run.agentId); if (pendingWakes.length > 0) { - // Process the first wake immediately; additional wakes will be - // re-enqueued by wakeAgentIfPossible if the agent is still busy. for (const wake of pendingWakes) { wakeAgentIfPossible(run.agentId, wake.companyId, wake.trigger); } } + + processRunCompletion(run, event as z.infer & { type: "run-finished" }); } }); } @@ -742,7 +1003,6 @@ async function checkConnector(id: Parameters[0]) { const connector = db.getConnector(id); const definition = getConnectorDefinition(id); - // Set "detected" (checking) state immediately so the UI shows progress db.updateConnectorHealth({ id, status: "detected", @@ -759,31 +1019,22 @@ async function checkConnector(id: Parameters[0]) { command: connector.command, model: connector.model, cwd: process.cwd(), - // These flags allow the health probe to run non-interactively. - // Without them, CLIs like Codex/Gemini enter interactive approval mode - // and the probe hangs or fails. - dangerouslyBypassApprovalsAndSandbox: true, // Codex - dangerouslySkipPermissions: true, // Claude - approvalMode: "yolo", // Gemini - sandbox: false, // Gemini - yolo: true, // Gemini fallback + dangerouslyBypassApprovalsAndSandbox: true, + dangerouslySkipPermissions: true, + approvalMode: "yolo", + sandbox: false, + yolo: true, extraArgs: id === "codex_local" ? ["--skip-git-repo-check"] : [], }, }); - const authState = - result.checks.some((check) => check.code.includes("auth") || check.code.includes("login")) - ? result.status === "fail" - ? "required" - : "ok" - : result.status === "fail" - ? "error" - : "ok"; + let authState: "ok" | "required" | "error" = "ok"; + if (result.status === "fail") { + const hasAuthCheck = result.checks.some((check) => check.code.includes("auth") || check.code.includes("login")); + authState = hasAuthCheck ? "required" : "error"; + } const version = await parseVersion(connector.command); - // Map adapter status to connector state: pass/warn → ready, fail → error/auth_required - // "warn" means the connector works but has non-fatal issues (e.g. missing API key that - // might be set at runtime) — still usable, so map to "ready" not "degraded" db.updateConnectorHealth({ id, status: @@ -838,23 +1089,26 @@ function ensureAgentHeartbeatEnabled(agentId: string) { function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string) { if (!agentId) return null; + try { + const agent = db.getAgent(agentId); + if (agent.companyId !== companyId) { + log.warn(`[wake] Refusing cross-company wake for agent ${agentId}: ${agent.companyId} !== ${companyId}`); + return null; + } + } catch { + return null; + } try { ensureAgentHeartbeatEnabled(agentId); } catch { return null; } - // Check if agent is specifically busy (has active run) before attempting wake. - // Only enqueue deferred wakes for the "busy" case, not for other skip reasons - // (inactive company, paused agent, budget exhaustion, connector not ready). const snapshot = db.listSnapshot(); - const hasActiveRun = snapshot.runs.some( - (run) => run.companyId === companyId && run.agentId === agentId && (run.status === "queued" || run.status === "running"), - ); + const agentBusy = hasActiveRun(snapshot, { agentId, companyId }); const runId = startAgentHeartbeatRun(agentId, companyId, trigger); - if (!runId && hasActiveRun) { - // Agent is busy with an active run — defer the wake so it's not lost + if (!runId && agentBusy) { db.enqueuePendingWake(companyId, agentId, trigger); } return runId; @@ -888,29 +1142,26 @@ function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: str return null; } const agent = db.getAgent(agentId); + if (agent.companyId !== companyId) { + log.warn(`[heartbeat] Refusing cross-company run for agent ${agentId}: ${agent.companyId} !== ${companyId}`); + return null; + } const connector = db.getConnector(agent.connectorId); if (getRunEligibilityFailure(agent, connector)) { return null; } - if ( - snapshot.runs.some( - (run) => run.companyId === companyId && run.agentId === agent.id && (run.status === "queued" || run.status === "running"), - ) - ) { + if (hasActiveRun(snapshot, { agentId: agent.id, companyId })) { log.info(`[heartbeat] Skipping ${agent.name} because it already has an active run.`); return null; } - // Phase 3: Approval hard gate — don't start heartbeat runs for pending-approval agents if (db.isAgentPendingApproval(agentId)) { log.info(`[heartbeat] Skipping ${agent.name} because agent is pending hire approval.`); return null; } - // Find an existing assigned task to run against const assignedTasks = db.listAgentTasks(agentId, companyId, ["todo", "in_progress", "blocked"]); - // For timer-triggered heartbeats, only pick up real user-created tasks (not old heartbeat tasks) const isTimer = trigger === "timer"; const candidateTasks = isTimer ? assignedTasks.filter((entry) => !entry.title.startsWith("[Heartbeat]")) @@ -920,188 +1171,84 @@ function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: str if (!task) { const hasActiveAssignedRun = candidateTasks.some( - (entry) => - entry.activeRunId && - snapshot.runs.some( - (run) => run.id === entry.activeRunId && run.companyId === companyId && (run.status === "queued" || run.status === "running"), - ), + (entry) => entry.activeRunId && hasActiveRun(snapshot, { taskId: entry.id, companyId }), ); if (hasActiveAssignedRun) { return null; } - // Timer should never create new heartbeat tasks — only explicit triggers can - if (isTimer) { + + // Even on timer triggers, the CEO/executives should wake up to create work + // when there are active goals. This is the bootstrap mechanism — goals exist + // but nobody has broken them into tasks yet. + const isCeo = !agent.reportsTo; + const isManager = snapshot.agents.some((a) => a.reportsTo === agent.id && a.companyId === companyId); + const activeGoals = snapshot.goals.filter((g) => g.companyId === companyId && (g.status === "active" || g.status === "planned")); + const companyTasks = snapshot.tasks.filter((t) => t.companyId === companyId && t.status !== "done" && t.status !== "cancelled"); + const hasUnassignedGoals = activeGoals.length > 0; + const companyNeedsBootstrap = hasUnassignedGoals && companyTasks.length < activeGoals.length; + + if (isTimer && !isCeo && !isManager && !companyNeedsBootstrap) { log.info(`[heartbeat] Skipping ${agent.name} — no actionable tasks and trigger is timer.`); return null; } - // Create a heartbeat check task (inherit workspace from agent) + + // Create a work-check task. For CEO/managers this drives planning. + // For ICs this only happens when the company is bootstrapping. + const taskTitle = isCeo + ? `[Heartbeat] ${agent.name} — Strategic planning & task delegation` + : isManager + ? `[Heartbeat] ${agent.name} — Review team & delegate work` + : `[Heartbeat] ${agent.name} — Check for work`; + const taskDesc = isCeo + ? `Review active company goals. Break each goal into projects and actionable tasks. Assign tasks to department leads and team members. Create tasks using the API. This is your primary responsibility as CEO — ensure every goal has a clear execution plan with assigned owners.` + : isManager + ? `Review your department's goals and your team's workload. Create tasks for your direct reports if they have capacity. Review any work submitted for your approval. Ensure your team is productive and unblocked.` + : `Automated heartbeat run for ${agent.name}. Review assigned tasks, check goals, and take action as needed.`; + const taskId = db.saveTask({ companyId, - title: `[Heartbeat] ${agent.name} — Check for work`, - description: `Automated heartbeat run for ${agent.name}. Review assigned tasks, check goals, and take action as needed.`, + title: taskTitle, + description: taskDesc, assigneeAgentId: agentId, workspaceId: agent.workspaceId ?? undefined, status: "in_progress", - priority: "medium", + priority: isCeo ? "high" : "medium", }); task = db.getTask(taskId); + log.info(`[heartbeat] Created bootstrap task for ${agent.name}: ${taskTitle}`); } - if ( - task.activeRunId || - snapshot.runs.some( - (run) => run.taskId === task.id && run.companyId === companyId && (run.status === "queued" || run.status === "running"), - ) - ) { + if (task.activeRunId || hasActiveRun(snapshot, { taskId: task.id, companyId })) { log.info(`[heartbeat] Skipping ${agent.name} because task ${task.title} already has an active run.`); return null; } - const workspace = - db.getWorkspace(task.workspaceId) ?? - db.getWorkspace(agent.workspaceId) ?? - db.getProjectPrimaryWorkspace(companyId) ?? - db.getAnyCompanyWorkspace(companyId); + const workspaceResolution = resolveWorkspace(task, agent, companyId); + const workspace = workspaceResolution.workspace; log.info( - `[heartbeat] Workspace resolution for ${agent.name}: task.workspaceId=${task.workspaceId ?? "null"}, agent.workspaceId=${agent.workspaceId ?? "null"}, resolved=${workspace?.name ?? "NONE"}, localPath=${workspace?.localPath ?? "NONE"}`, + `[heartbeat] Workspace resolution for ${agent.name}: task.workspaceId=${task.workspaceId ?? "null"}, agent.workspaceId=${agent.workspaceId ?? "null"}, projectId=${task.projectId ?? "null"}, source=${workspaceResolution.source}, mode=${workspaceResolution.lockMode}, resolved=${workspace?.name ?? "NONE"}, localPath=${workspace?.localPath ?? "NONE"}`, ); - // Attempt workspace lock BEFORE creating the run — if another run holds it, defer - if (workspace && !db.acquireWorkspaceLock(workspace.id, `pre-check-${agent.id}`)) { - log.info(`[heartbeat] Skipping ${agent.name} because workspace ${workspace.name} is locked by another run.`); - // Release the pre-check placeholder - db.releaseWorkspaceLock(workspace.id, `pre-check-${agent.id}`); - return null; - } - // Release pre-check — the real lock will be acquired with the actual run ID below - if (workspace) { - db.releaseWorkspaceLock(workspace.id, `pre-check-${agent.id}`); - } - const run = db.createRun({ companyId, taskId: task.id, agentId: agent.id, - workspaceId: workspace?.id ?? null, + workspaceId: workspaceResolution.lockMode === "exclusive" ? (workspace?.id ?? null) : null, connectorId: connector.id, }); - // Acquire workspace lock with the real run ID - if (workspace) { - const locked = db.acquireWorkspaceLock(workspace.id, run.id); - if (!locked) { - // Race condition: someone else grabbed the lock between pre-check and here - log.warn(`[heartbeat] Workspace lock race for ${workspace.name}, aborting run ${run.id}`); - db.finishRun({ runId: run.id, status: "cancelled", summary: "Workspace lock contention.", errorMessage: null, exitCode: null, signal: null, model: null, sessionDisplayId: null, costUsd: null, unattributedCost: true }); - return null; - } - } - - const env = db.resolveSecretEnv(connector.envBindingText, companyId); - - // Build rich prompt with API context - const apiPort = apiServer?.getPort() ?? 0; - const apiUrl = apiPort ? `http://127.0.0.1:${apiPort}` : ""; - const apiKey = apiUrl ? createAgentToken(agent.id, companyId, run.id) : ""; - - const heartbeatRecentComments = snapshot.comments - .filter((c) => assignedTasks.some((t) => t.id === c.taskId)) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .slice(0, 20) - .map((c) => ({ - taskId: c.taskId, - taskTitle: assignedTasks.find((t) => t.id === c.taskId)?.title ?? c.taskId, - authorName: c.authorName, - body: c.body, - createdAt: c.createdAt, - })); - const systemPrompt = apiUrl ? buildTaskPrompt({ - agent, - apiUrl, - apiKey, + dispatchRunToWorker({ runId: run.id, + companyId, + agent, task, + connector, + workspace, + workspaceMode: workspaceResolution.lockMode, companyName: company.name, - goals: snapshot.goals.filter((g) => g.companyId === companyId), - projects: snapshot.projects.filter((p) => p.companyId === companyId), - directReports: db.getDirectReports(agent.id, companyId), - chainOfCommand: db.getChainOfCommand(agent.id), - assignedTasks: assignedTasks, - allAgents: snapshot.agents.filter((a) => a.companyId === companyId), - pendingApprovals: snapshot.approvals.filter( - (a) => a.companyId === companyId && - a.requestedByAgentId === agent.id && - (a.state === "pending" || a.state === "revision_requested"), - ), - tasksAwaitingReview: db.listReviewTasksForManager(agent.id, companyId), - wakeReason: trigger, - allTasks: snapshot.tasks.filter((t) => t.companyId === companyId), - recentComments: heartbeatRecentComments, companyDescription: company.description ?? "", - socialAccounts: db.listSocialAccounts(companyId).map(a => ({ - id: a.id, - platform: a.platform, - accountName: a.accountName, - status: a.status, - requireApproval: a.requireApproval, - })), - recentMessages: db.listAgentMessages(companyId, { agentId: agent.id, limit: 15 }), - upcomingMeetings: snapshot.meetings - .filter((m: any) => m.companyId === companyId && (m.status === "scheduled" || m.status === "in_progress")) - .filter((m: any) => { - try { const ids = JSON.parse(m.participantAgentIds); return ids.includes(agent.id) || m.organizerAgentId === agent.id; } catch { return false; } - }) - .slice(0, 5), - relevantKnowledge: snapshot.knowledgeBase - .filter((k: any) => k.companyId === companyId && (k.importance === "critical" || k.importance === "high")) - .slice(0, 8), - activeSprint: snapshot.sprints.find((s: any) => s.companyId === companyId && s.status === "active") ?? null, - recentDocuments: snapshot.documents - .filter((d: any) => d.companyId === companyId && (d.authorAgentId === agent.id || d.reviewerAgentId === agent.id || d.status === "in_review")) - .slice(0, 5), - }) : null; - - // Add API credentials to env - if (apiUrl) { - env.AGENT_COMPANY_API_URL = apiUrl; - env.AGENT_COMPANY_API_KEY = apiKey; - env.AGENT_COMPANY_AGENT_ID = agent.id; - env.AGENT_COMPANY_COMPANY_ID = companyId; - env.AGENT_COMPANY_RUN_ID = run.id; - env.AGENT_COMPANY_TASK_ID = task.id; - env.AGENT_COMPANY_WAKE_REASON = trigger; - } - - const session = db.getTaskSession(task.id); - let thinkingEffort: string | null = null; - try { - const meta = JSON.parse(agent.metadataJson || "{}"); - if (typeof meta.thinkingEffort === "string" && meta.thinkingEffort) { - thinkingEffort = meta.thinkingEffort; - } - } catch { /* ignore malformed json */ } - - worker.postMessage({ - type: "enqueue-run", - payload: { - runId: run.id, - companyId, - taskId: task.id, - taskTitle: task.title, - taskDescription: task.description, - agentId: agent.id, - agentName: agent.name, - connectorId: connector.id, - workspaceId: workspace?.id ?? null, - workspacePath: workspace?.localPath ?? null, - command: connector.command, - model: connector.model ?? agent.model, - thinkingEffort, - env, - sessionParams: session.sessionParams, - systemPrompt, - trigger, - }, + snapshot, + trigger, }); db.markHeartbeatAt(agentId); @@ -1117,11 +1264,849 @@ function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: str return run.id; } +// ── Automation Engine ────────────────────────────────────────────────── + +interface AutomationEventContext { + companyId: string; + taskId?: string; + agentId?: string; + runId?: string; + projectId?: string; + department?: string; + previousStatus?: string; + newStatus?: string; + costUsd?: number; +} + +function evaluateAutomationRules(trigger: string, ctx: AutomationEventContext) { + try { + const rules = db.listActiveAutomationRules(ctx.companyId, trigger); + for (const rule of rules) { + if (!matchesConditions(rule, ctx)) continue; + const executionKey = [ + rule.id, + trigger, + ctx.companyId, + ctx.taskId ?? "-", + ctx.agentId ?? "-", + ctx.runId ?? "-", + ctx.previousStatus ?? "-", + ctx.newStatus ?? "-", + ].join(":"); + if (activeAutomationExecutions.has(executionKey)) { + log.warn(`[automation] Skipping re-entrant execution for rule "${rule.name}" (${executionKey})`); + continue; + } + activeAutomationExecutions.add(executionKey); + try { + executeAutomationAction(rule, ctx); + db.recordAutomationExecution( + rule.id, ctx.companyId, rule.name, trigger, rule.action, + `Fired for ${trigger} in context: taskId=${ctx.taskId ?? "n/a"}, agentId=${ctx.agentId ?? "n/a"}`, + ); + emitEvent({ + type: "automation-fired", + ruleId: rule.id, + ruleName: rule.name, + trigger: trigger as import("@shared/types").AutomationTrigger, + action: rule.action as import("@shared/types").AutomationAction, + }); + log.info(`[automation] Fired rule "${rule.name}" (${trigger} → ${rule.action})`); + } catch (err) { + log.error(`[automation] Failed to execute action for rule "${rule.name}": ${err instanceof Error ? err.message : String(err)}`); + db.recordAutomationExecution( + rule.id, ctx.companyId, rule.name, trigger, rule.action, + `FAILED: ${err instanceof Error ? err.message : String(err)}`, + ); + } finally { + activeAutomationExecutions.delete(executionKey); + } + } + } catch (err) { + log.error(`[automation] Error evaluating rules for trigger "${trigger}": ${err instanceof Error ? err.message : String(err)}`); + } +} + +function matchesEventConfig(configJson: string, sourceDepartment: string | null, ctx: AutomationEventContext): boolean { + if (sourceDepartment && ctx.department && sourceDepartment !== ctx.department) { + return false; + } + try { + const conditions = JSON.parse(configJson) as Record; + if (Object.keys(conditions).length === 0) return true; + if (conditions.status && ctx.newStatus && conditions.status !== ctx.newStatus) return false; + if (conditions.previousStatus && ctx.previousStatus && conditions.previousStatus !== ctx.previousStatus) return false; + if (conditions.priority) { + try { + const task = ctx.taskId ? db.getTask(ctx.taskId) : null; + if (task && task.priority !== conditions.priority) return false; + } catch { + return false; + } + } + if (typeof conditions.budgetThresholdPct === "number" && ctx.agentId) { + try { + const agent = db.getAgent(ctx.agentId); + if (agent.budgetMonthlyUsd > 0) { + const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd; + if (utilization < (conditions.budgetThresholdPct as number) / 100) return false; + } else { + return false; + } + } catch { + return false; + } + } + if (conditions.taskId && ctx.taskId && conditions.taskId !== ctx.taskId) return false; + if (conditions.projectId && ctx.projectId && conditions.projectId !== ctx.projectId) return false; + if (conditions.agentId && ctx.agentId && conditions.agentId !== ctx.agentId) return false; + return true; + } catch { + return true; + } +} + +function matchesConditions( + rule: { conditionsJson: string; sourceDepartment: string | null; targetDepartment: string | null }, + ctx: AutomationEventContext, +): boolean { + void rule.targetDepartment; + return matchesEventConfig(rule.conditionsJson, rule.sourceDepartment, ctx); +} + +function evaluateWorkflowTriggers(trigger: string, ctx: AutomationEventContext) { + try { + const workflows = db.listTriggerableWorkflows(ctx.companyId, trigger); + for (const workflow of workflows) { + if (!matchesEventConfig(workflow.triggerConfigJson, null, ctx)) continue; + const executionKey = [ + workflow.id, + trigger, + ctx.companyId, + ctx.taskId ?? "-", + ctx.agentId ?? "-", + ctx.runId ?? "-", + ctx.previousStatus ?? "-", + ctx.newStatus ?? "-", + ].join(":"); + if (activeWorkflowTriggerExecutions.has(executionKey)) { + log.warn(`[workflow] Skipping re-entrant trigger for workflow "${workflow.name}" (${executionKey})`); + continue; + } + activeWorkflowTriggerExecutions.add(executionKey); + void runWorkflowPipeline(workflow.id, ctx.companyId) + .then(() => { + log.info(`[workflow] Triggered workflow "${workflow.name}" via ${trigger}`); + }) + .catch((error) => { + log.error(`[workflow] Trigger failed for "${workflow.name}": ${error instanceof Error ? error.message : String(error)}`); + }) + .finally(() => { + activeWorkflowTriggerExecutions.delete(executionKey); + }); + } + } catch (error) { + log.error(`[workflow] Error evaluating workflow triggers for "${trigger}": ${error instanceof Error ? error.message : String(error)}`); + } +} + +function dispatchAutomation(trigger: string, ctx: AutomationEventContext) { + evaluateAutomationRules(trigger, ctx); + evaluateWorkflowTriggers(trigger, ctx); +} + +function executeAutomationAction( + rule: { action: string; actionConfigJson: string; targetDepartment: string | null; trigger?: string }, + ctx: AutomationEventContext, +) { + const config = (() => { try { return JSON.parse(rule.actionConfigJson) as Record; } catch { return {}; } })(); + + switch (rule.action) { + case "assign_task": { + if (ctx.taskId) { + const assignedId = db.autoAssignTask(ctx.taskId, ctx.companyId); + if (assignedId) { + wakeAgentIfPossible(assignedId, ctx.companyId, "assignment"); + } + } else if (ctx.agentId) { + // No specific task — find unassigned tasks and assign to the idle agent + const snapshot = db.listSnapshot(); + const unassigned = snapshot.tasks + .filter((t) => t.companyId === ctx.companyId && !t.assigneeAgentId && ["backlog", "todo"].includes(t.status)) + .sort((a, b) => { + const prio = { critical: 4, high: 3, medium: 2, low: 1 } as Record; + return (prio[b.priority] ?? 0) - (prio[a.priority] ?? 0); + }); + if (unassigned.length > 0) { + const task = unassigned[0]; + try { + db.saveTask({ + id: task.id, + companyId: task.companyId, + projectId: task.projectId, + goalId: task.goalId, + parentId: task.parentId, + title: task.title, + description: task.description, + assigneeAgentId: ctx.agentId, + workspaceId: task.workspaceId, + priority: task.priority, + status: "todo", + }); + wakeAgentIfPossible(ctx.agentId, ctx.companyId, "assignment"); + log.info(`[automation] Auto-assigned task "${task.title}" to idle agent ${ctx.agentId}`); + } catch (err) { + log.warn(`[automation] Failed to auto-assign task to idle agent: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + break; + } + case "create_task": { + const title = (config.title as string) ?? `[Auto] Task from automation rule`; + const description = (config.description as string) ?? ""; + const priority = (config.priority as string) ?? "medium"; + const assigneeAgentId = (config.assigneeAgentId as string) ?? null; + const taskId = db.saveTask({ + companyId: ctx.companyId, + projectId: ctx.projectId ?? (config.projectId as string) ?? null, + title, + description, + assigneeAgentId, + priority, + status: assigneeAgentId ? "todo" : "backlog", + }); + if (assigneeAgentId) { + wakeAgentIfPossible(assigneeAgentId, ctx.companyId, "assignment"); + } else { + // No explicit assignee — try to auto-assign without triggering automation recursion + const autoAssigned = db.autoAssignTask(taskId, ctx.companyId); + if (autoAssigned) { + wakeAgentIfPossible(autoAssigned, ctx.companyId, "assignment"); + } + } + log.info(`[automation] Created task: ${title} (${taskId})`); + break; + } + case "notify_agent": + case "send_message": { + const toAgentId = (config.toAgentId as string) ?? ctx.agentId ?? null; + const fromAgentId = (config.fromAgentId as string) ?? ctx.agentId ?? toAgentId; + if (!fromAgentId) break; + const channel = (config.channel as string) ?? (toAgentId ? "direct" : "company"); + // For department channel, auto-resolve dept from the agent if not specified + let channelTargetId: string | null = (config.channelTargetId as string) ?? (config.department as string) ?? ctx.department ?? null; + if (channel === "department" && !channelTargetId && fromAgentId) { + try { + const sender = db.getAgent(fromAgentId); + channelTargetId = sender.department ?? null; + } catch { /* ok */ } + } + db.sendAgentMessage({ + companyId: ctx.companyId, + fromAgentId, + toAgentId: channel === "direct" ? toAgentId : null, + channel, + channelTargetId, + subject: (config.subject as string) ?? `Automation notification`, + body: (config.body as string) ?? `Automation rule triggered for task ${ctx.taskId ?? "n/a"}`, + priority: (config.priority as string) ?? "normal", + }); + // Emit event so renderer updates in real-time + const newMsg = db.listAgentMessages(ctx.companyId, { limit: 1 })?.[0]; + if (newMsg) emitEvent({ type: "new-message", message: newMsg }); + publishDomainChanged(); + if (toAgentId && channel === "direct") { + wakeAgentIfPossible(toAgentId, ctx.companyId, "message"); + } + break; + } + case "trigger_heartbeat": { + let targetAgentId = (config.agentId as string) ?? null; + if (!targetAgentId && ctx.agentId) { + // If no explicit target, wake the agent from context. + // But for run_completed/run_failed triggers, the context agent already just ran — + // waking them again is redundant. Instead, wake their manager if possible. + const triggerType = rule.trigger ?? ""; + if (triggerType === "run_completed" || triggerType === "run_failed") { + const manager = db.getManagerForAgent(ctx.agentId); + targetAgentId = manager?.id ?? ctx.agentId; + } else { + targetAgentId = ctx.agentId; + } + } + if (targetAgentId) { + wakeAgentIfPossible(targetAgentId, ctx.companyId, "manual"); + } + break; + } + case "create_approval": { + const approvalType = (config.approvalType as string) ?? "document_review"; + db.requestApproval({ + companyId: ctx.companyId, + relatedTaskId: ctx.taskId ?? null, + requestedByAgentId: ctx.agentId ?? null, + type: approvalType, + payloadSummary: (config.summary as string) ?? `Auto-generated approval request`, + impactSummary: (config.impact as string) ?? `Triggered by automation rule`, + }); + break; + } + case "update_task_status": { + if (!ctx.taskId) break; + const newStatus = (config.status as string) ?? "todo"; + db.advanceTaskStatus(ctx.taskId, newStatus, `Status changed by automation rule`); + publishDomainChanged(); + // Propagate essential downstream effects without re-triggering automation (to prevent loops) + try { + const updatedTask = db.getTask(ctx.taskId); + if (newStatus === "blocked" && updatedTask.assigneeAgentId) { + const manager = db.getManagerForAgent(updatedTask.assigneeAgentId); + if (manager) wakeAgentIfPossible(manager.id, ctx.companyId, "report_blocked"); + } + if (newStatus === "in_review" && updatedTask.assigneeAgentId) { + const manager = db.getManagerForAgent(updatedTask.assigneeAgentId); + if (manager) wakeAgentIfPossible(manager.id, ctx.companyId, "subtask_completed"); + } + if (newStatus === "todo" && updatedTask.assigneeAgentId) { + wakeAgentIfPossible(updatedTask.assigneeAgentId, ctx.companyId, "assignment"); + } + if (newStatus === "done" && updatedTask.parentId) { + const snapshot = db.listSnapshot(); + const siblings = snapshot.tasks.filter((t) => t.parentId === updatedTask.parentId && t.companyId === ctx.companyId); + const allDone = siblings.every((t) => t.id === ctx.taskId ? true : t.status === "done"); + if (allDone) { + const parent = db.getTask(updatedTask.parentId); + if (parent.assigneeAgentId) wakeAgentIfPossible(parent.assigneeAgentId, ctx.companyId, "subtask_completed"); + } + } + } catch { /* task may not exist */ } + break; + } + case "escalate_to_manager": { + const targetAgentId = ctx.agentId; + if (!targetAgentId) break; + const manager = db.getManagerForAgent(targetAgentId); + if (manager) { + let agentName = "an agent"; + try { agentName = db.getAgent(targetAgentId).name; } catch { /* ok */ } + let taskTitle = ctx.taskId ?? "unknown"; + try { if (ctx.taskId) taskTitle = db.getTask(ctx.taskId).title; } catch { /* ok */ } + db.sendAgentMessage({ + companyId: ctx.companyId, + fromAgentId: targetAgentId, + toAgentId: manager.id, + channel: "direct", + subject: `Escalation from ${agentName}: ${taskTitle}`, + body: (config.body as string) ?? `Task "${taskTitle}" needs your attention. ${agentName} has flagged this as requiring manager intervention.`, + priority: "urgent", + }); + publishDomainChanged(); + wakeAgentIfPossible(manager.id, ctx.companyId, "report_blocked"); + } + break; + } + case "cross_department_notify": { + const targetDept = rule.targetDepartment ?? (config.department as string); + if (!targetDept) break; + const deptAgents = db.getIdleAgentsForDepartment(ctx.companyId, targetDept); + // Send a single channel message visible to entire department + const senderAgentId = ctx.agentId ?? deptAgents[0]?.id; + if (senderAgentId) { + db.sendAgentMessage({ + companyId: ctx.companyId, + fromAgentId: senderAgentId, + toAgentId: null, + channel: "department", + channelTargetId: targetDept, + subject: (config.subject as string) ?? `Cross-department notification`, + body: (config.body as string) ?? `Automation triggered a cross-department alert.`, + priority: "normal", + }); + publishDomainChanged(); + } + // Wake agents in the target department + for (const agent of deptAgents.slice(0, 5)) { + wakeAgentIfPossible(agent.id, ctx.companyId, "message"); + } + break; + } + case "schedule_meeting": { + const meetingType = (config.meetingType as string) ?? "department_sync"; + const title = (config.title as string) ?? `Auto-scheduled meeting`; + db.saveMeeting({ + companyId: ctx.companyId, + type: meetingType, + title, + organizerAgentId: ctx.agentId ?? null, + participantAgentIds: (config.participantAgentIds as string) ?? "[]", + scheduledAt: new Date(Date.now() + 3600_000).toISOString(), + durationMinutes: (config.durationMinutes as number) ?? 30, + }); + break; + } + case "create_document": { + const docType = (config.documentType as string) ?? "status_report"; + const title = (config.title as string) ?? `Auto-generated document`; + db.saveDocument({ + companyId: ctx.companyId, + type: docType, + title, + content: (config.content as string) ?? "", + authorAgentId: ctx.agentId ?? null, + projectId: ctx.projectId ?? null, + }); + break; + } + case "auto_approve": { + const snapshot = db.listSnapshot(); + const pendingApprovals = snapshot.approvals.filter( + (a) => a.companyId === ctx.companyId && a.state === "pending", + ); + for (const approval of pendingApprovals.slice(0, 5)) { + db.decideApproval({ companyId: ctx.companyId, approvalId: approval.id, state: "approved", decisionNote: "Auto-approved by automation rule." }); + handleApprovalResolved(approval.id, ctx.companyId, "approved"); + } + break; + } + case "reassign_task": { + if (!ctx.taskId) break; + const newAssignee = db.autoAssignTask(ctx.taskId, ctx.companyId); + if (newAssignee) { + wakeAgentIfPossible(newAssignee, ctx.companyId, "assignment"); + } + break; + } + case "run_task": { + const targetTaskId = (config.taskId as string) ?? ctx.taskId; + if (!targetTaskId) break; + try { + const task = db.getTask(targetTaskId); + if (!task.assigneeAgentId) break; + wakeAgentIfPossible(task.assigneeAgentId, ctx.companyId, "assignment"); + } catch { /* task may not exist */ } + break; + } + default: + log.warn(`[automation] Unknown action: ${rule.action}`); + } +} + +// ── Run Result Processing ───────────────────────────────────────────── + +function processRunCompletion(run: RunRecord, event: z.infer & { type: "run-finished" }) { + let task: ReturnType | null = null; + try { task = db.getTask(run.taskId); } catch { return; } + if (!task) return; + + const isSuccess = event.status === "succeeded"; + const isFailed = event.status === "failed" || event.status === "timed_out"; + + // Auto-advance task on successful run: in_progress → in_review + if (isSuccess && task.status === "in_progress") { + db.advanceTaskStatus(task.id, "in_review", `Run completed successfully. Awaiting review.`); + publishDomainChanged(); + + // Wake the manager to review the work + if (task.assigneeAgentId) { + const manager = db.getManagerForAgent(task.assigneeAgentId); + if (manager) { + db.sendAgentMessage({ + companyId: run.companyId, + fromAgentId: task.assigneeAgentId, + toAgentId: manager.id, + channel: "direct", + subject: `Review requested: ${task.title}`, + body: `Task "${task.title}" has been completed and is ready for review.\n\nRun summary: ${event.summary ?? "No summary available."}`, + priority: "normal", + }); + wakeAgentIfPossible(manager.id, run.companyId, "subtask_completed"); + } + } + } + + // On successful heartbeat check-work task, clean up if no real work done + if (isSuccess && task.title.startsWith("[Heartbeat]") && task.status !== "done") { + db.advanceTaskStatus(task.id, "done", "Heartbeat check completed."); + } + + const automationCtx: AutomationEventContext = { + companyId: run.companyId, + taskId: run.taskId, + agentId: run.agentId, + runId: run.id, + costUsd: event.costUsd ?? undefined, + }; + try { + const agent = db.getAgent(run.agentId); + automationCtx.department = agent.department ?? undefined; + } catch { /* agent may be deleted */ } + + if (isSuccess) { + dispatchAutomation("run_completed", automationCtx); + + // Check if all subtasks of a parent task are done — use fresh snapshot since task status just changed + if (task.parentId) { + try { + const freshSnapshot = db.listSnapshot(); + const siblings = freshSnapshot.tasks.filter((t) => t.parentId === task!.parentId && t.companyId === run.companyId); + const allSiblingsDoneOrReview = siblings.every((t) => t.status === "done" || t.status === "in_review"); + if (allSiblingsDoneOrReview) { + const parent = db.getTask(task.parentId); + if (parent.assigneeAgentId) { + wakeAgentIfPossible(parent.assigneeAgentId, run.companyId, "subtask_completed"); + } else { + // Parent has no assignee — auto-assign to the agent who would be responsible + const assignedId = db.autoAssignTask(parent.id, run.companyId); + if (assignedId) { + wakeAgentIfPossible(assignedId, run.companyId, "subtask_completed"); + } + } + } + } catch { /* parent may be deleted */ } + } + } + + if (isFailed) { + dispatchAutomation("run_failed", automationCtx); + } + + // Budget threshold check after cost is recorded + if (event.costUsd && event.costUsd > 0) { + try { + const agent = db.getAgent(run.agentId); + if (agent.budgetMonthlyUsd > 0) { + const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd; + if (utilization >= 0.8) { + dispatchAutomation("budget_threshold", { + ...automationCtx, + agentId: agent.id, + }); + } + } + } catch { /* agent deleted */ } + } +} + +function handleApprovalResolved(approvalId: string, companyId: string, decision: string) { + const snapshot = db.listSnapshot(); + const approval = snapshot.approvals.find((a) => a.id === approvalId); + if (!approval) return; + + dispatchAutomation("approval_resolved", { + companyId, + agentId: approval.requestedByAgentId ?? undefined, + taskId: approval.relatedTaskId ?? undefined, + }); + + // `db.decideApproval` already updates the hire target. This path only emits + // post-resolution side effects once the DB state is committed. + if (approval.type === "hire_agent" && decision === "approved" && approval.relatedAgentId) { + try { + const agent = db.getAgent(approval.relatedAgentId); + if (agent.status === "idle") { + db.addActivity({ + companyId, + actor: "system", + action: "agent.hired", + entityType: "agent", + entityId: agent.id, + detail: `${agent.name} hire approved and activated.`, + }); + dispatchAutomation("hire_approved", { + companyId, + agentId: agent.id, + }); + // Wake the new agent so they check in + wakeAgentIfPossible(agent.id, companyId, "assignment"); + publishDomainChanged(); + notify("New agent hired", `${agent.name} (${agent.role}) has joined the company.`); + } + } catch { /* agent may be deleted */ } + } + + const browserAction = db.getBrowserActionByApprovalId(approvalId, companyId); + if (browserAction) { + if (decision === "approved") { + db.updateBrowserAction(browserAction.id, { + status: "queued", + resultSummary: "Approval granted. Dispatching browser action.", + errorMessage: null, + }); + publishDomainChanged(); + void dispatchBrowserAction(browserAction.id, companyId).catch((error) => { + log.error(`[browser] Failed to dispatch approved browser action ${browserAction.id}: ${error instanceof Error ? error.message : String(error)}`); + }); + } else if (decision === "rejected") { + db.updateBrowserAction(browserAction.id, { + status: "cancelled", + resultSummary: "Browser action was rejected during approval.", + errorMessage: "Approval rejected by board.", + finishedAt: new Date().toISOString(), + }); + publishDomainChanged(); + } else if (decision === "revision_requested") { + db.updateBrowserAction(browserAction.id, { + status: "approval_required", + resultSummary: "Board requested revisions before execution.", + errorMessage: "Approval requires revisions before the browser action can run.", + }); + publishDomainChanged(); + } + } + + if (approval.payloadJson) { + try { + const payload = JSON.parse(approval.payloadJson) as { workflowId?: string; stepId?: string }; + if (payload.workflowId && payload.stepId) { + const workflow = db.getWorkflowRecord(payload.workflowId, companyId); + if (workflow) { + const steps = normalizeWorkflowSteps(JSON.parse(workflow.stepsJson || "[]")); + const stepIndex = steps.findIndex((step) => step.id === payload.stepId); + if (stepIndex >= 0) { + if (decision === "approved") { + steps[stepIndex]!.status = "pending"; + db.updateWorkflowExecution({ + id: workflow.id, + companyId, + status: "paused", + currentStepIndex: stepIndex, + stepsJson: JSON.stringify(steps), + lastRunAt: workflow.lastRunAt, + }); + publishDomainChanged(); + void runWorkflowPipeline(workflow.id, companyId).catch((error) => { + log.error(`[workflow] Failed to resume workflow ${workflow.id} after approval ${approvalId}: ${error instanceof Error ? error.message : String(error)}`); + }); + } else { + steps[stepIndex]!.status = decision === "revision_requested" ? "pending" : "failed"; + db.updateWorkflowExecution({ + id: workflow.id, + companyId, + status: decision === "revision_requested" ? "paused" : "failed", + currentStepIndex: stepIndex, + stepsJson: JSON.stringify(steps), + lastRunAt: workflow.lastRunAt, + }); + publishDomainChanged(); + } + } + } + } + } catch { + // Non-workflow approvals may use payloadJson for other purposes. + } + } + + // Wake the requesting agent to inform them of the decision + if (approval.requestedByAgentId) { + wakeAgentIfPossible(approval.requestedByAgentId, companyId, "approval_resolved"); + } +} + +function handleTaskStatusChange(taskId: string, companyId: string, previousStatus: string, newStatus: string) { + if (previousStatus === newStatus) return; + + let task: ReturnType | null = null; + try { task = db.getTask(taskId); } catch { return; } + if (!task) return; + + db.addActivity({ + companyId, + actor: task.assigneeAgentId ?? "board", + action: "task.status_changed", + entityType: "task", + entityId: taskId, + detail: `${previousStatus} → ${newStatus}`, + }); + + const ctx: AutomationEventContext = { + companyId, + taskId, + agentId: task.assigneeAgentId ?? undefined, + previousStatus, + newStatus, + }; + try { + if (task.assigneeAgentId) { + const agent = db.getAgent(task.assigneeAgentId); + ctx.department = agent.department ?? undefined; + } + } catch { /* agent deleted */ } + + dispatchAutomation("task_status_changed", ctx); + + if (newStatus === "blocked") { + dispatchAutomation("task_blocked", ctx); + // Auto-escalate blocked tasks to manager + if (task.assigneeAgentId) { + const manager = db.getManagerForAgent(task.assigneeAgentId); + if (manager) { + wakeAgentIfPossible(manager.id, companyId, "report_blocked"); + } + } + } + + // When task enters in_review, wake the manager to review + if (newStatus === "in_review" && task.assigneeAgentId) { + const manager = db.getManagerForAgent(task.assigneeAgentId); + if (manager) { + wakeAgentIfPossible(manager.id, companyId, "subtask_completed"); + } + } + + // When task is assigned (status change to todo with an assignee), wake the agent + if (newStatus === "todo" && task.assigneeAgentId) { + wakeAgentIfPossible(task.assigneeAgentId, companyId, "assignment"); + } + + // When a subtask is completed (done), check if all sibling subtasks are also done. + // If so, wake the parent task's assignee — this enables the full task completion cascade + // even when tasks are approved manually (not just via agent run completion). + if (newStatus === "done" && task.parentId) { + try { + const snapshot = db.listSnapshot(); + const siblings = snapshot.tasks.filter((t) => t.parentId === task!.parentId && t.companyId === companyId); + const allDone = siblings.every((t) => t.id === taskId ? true : t.status === "done"); + if (allDone) { + const parent = db.getTask(task.parentId); + if (parent.assigneeAgentId) { + wakeAgentIfPossible(parent.assigneeAgentId, companyId, "subtask_completed"); + } else { + const assignedId = db.autoAssignTask(parent.id, companyId); + if (assignedId) { + wakeAgentIfPossible(assignedId, companyId, "subtask_completed"); + } + } + } + } catch { /* parent may be deleted */ } + } +} + +function handleTaskCreated(taskId: string, companyId: string) { + let task: ReturnType | null = null; + try { task = db.getTask(taskId); } catch { return; } + if (!task) return; + + dispatchAutomation("task_created", { + companyId, + taskId, + agentId: task.assigneeAgentId ?? undefined, + }); + + if (!task.assigneeAgentId) { + const assignedId = db.autoAssignTask(taskId, companyId); + if (assignedId) { + wakeAgentIfPossible(assignedId, companyId, "assignment"); + } + } else { + wakeAgentIfPossible(task.assigneeAgentId, companyId, "assignment"); + } +} + +function generateInitialCompanyMessages( + companyId: string, + template: { name: string; agents: Array<{ name: string; role: string; title: string; department: string }> }, + agentIds: string[], +) { + try { + const ceoId = agentIds[0]; + const ceoName = template.agents[0]?.name ?? "CEO"; + + // CEO sends company-wide welcome + db.sendAgentMessage({ + companyId, + fromAgentId: ceoId, + toAgentId: null, + channel: "company", + subject: `Welcome to ${template.name}`, + body: `Team, I'm ${ceoName} and I'm excited to kick off our company. Our mission is clear and our team is assembled. Each department lead: please introduce your team in your department channel and outline your initial priorities. Let's build something great together.`, + priority: "normal", + }); + + // Group agents by department and have leads introduce themselves + const deptAgents = new Map>(); + for (let i = 0; i < template.agents.length; i++) { + const def = template.agents[i]; + const dept = def.department; + if (!deptAgents.has(dept)) deptAgents.set(dept, []); + deptAgents.get(dept)!.push({ id: agentIds[i], name: def.name, role: def.role, title: def.title, index: i }); + } + + for (const [dept, members] of deptAgents) { + if (dept === "executive" && members.length <= 1) continue; // Skip if only CEO + const lead = members[0]; // First agent in department is the lead + const otherMembers = members.slice(1); + const memberList = otherMembers.length > 0 + ? `Our team: ${otherMembers.map((m) => `${m.name} (${m.title})`).join(", ")}.` + : "I'm currently the sole member of this department."; + + db.sendAgentMessage({ + companyId, + fromAgentId: lead.id, + toAgentId: null, + channel: "department", + channelTargetId: dept, + subject: `${dept.charAt(0).toUpperCase() + dept.slice(1).replace("_", " ")} department reporting in`, + body: `I'm ${lead.name}, ${lead.title}. ${memberList} We're ready to start executing on our goals. I'll be coordinating our work and reporting progress here.`, + priority: "normal", + }); + + // Each non-lead member sends a brief introduction + for (const member of otherMembers) { + db.sendAgentMessage({ + companyId, + fromAgentId: member.id, + toAgentId: null, + channel: "department", + channelTargetId: dept, + subject: `Introduction`, + body: `Hello team, I'm ${member.name}, ${member.title}. Ready to contribute. Looking forward to working with everyone.`, + priority: "low", + }); + } + } + + // CEO sends a strategy message + db.sendAgentMessage({ + companyId, + fromAgentId: ceoId, + toAgentId: null, + channel: "company", + subject: "Initial priorities and next steps", + body: `Here's our immediate game plan:\n\n1. All department leads: review our company goals and identify your department's top 3 priorities\n2. Engineering: set up workspaces and assess our technical infrastructure\n3. Product: draft initial requirements for our first deliverable\n4. All agents: check your assigned tasks and begin execution\n\nI'll be reviewing progress and providing direction. Don't hesitate to escalate blockers — speed matters more than perfection right now.`, + priority: "normal", + }); + + // Department leads report back to CEO with initial priorities + for (const [dept, members] of deptAgents) { + if (dept === "executive") continue; + const lead = members[0]; + if (!lead) continue; + + db.sendAgentMessage({ + companyId, + fromAgentId: lead.id, + toAgentId: ceoId, + channel: "direct", + subject: `${dept.replace("_", " ")} department initial assessment`, + body: `${ceoName}, this is ${lead.name} from the ${dept.replace("_", " ")} department. I've reviewed our company goals and I'm ready to break them down into actionable tasks for my team. I'll start creating tasks and assigning work to my direct reports immediately. Will report back with our sprint plan shortly.`, + priority: "normal", + }); + } + + log.info(`[company] Generated initial messages for company ${companyId}`); + } catch (err) { + log.error(`[company] Failed to generate initial messages: ${err instanceof Error ? err.message : String(err)}`); + } +} + function startHeartbeatScheduler() { if (heartbeatTimer) clearInterval(heartbeatTimer); heartbeatTimer = setInterval(() => { try { + if (!worker) return; const snapshot = db.listSnapshot(); const now = Date.now(); @@ -1129,10 +2114,7 @@ function startHeartbeatScheduler() { try { const eligibleAgents = db.listHeartbeatEligibleAgents(company.id); for (const agent of eligibleAgents) { - const hasActiveRun = snapshot.runs.some( - (r) => r.agentId === agent.id && (r.status === "running" || r.status === "queued"), - ); - if (hasActiveRun) continue; + if (hasActiveRun(snapshot, { agentId: agent.id, companyId: company.id })) continue; const lastHeartbeat = agent.lastHeartbeatAt ? new Date(agent.lastHeartbeatAt).getTime() : 0; const intervalMs = agent.heartbeatIntervalSec * 1000; @@ -1141,18 +2123,208 @@ function startHeartbeatScheduler() { log.info(`[heartbeat] Waking agent ${agent.name} (interval: ${agent.heartbeatIntervalSec}s)`); startAgentHeartbeatRun(agent.id, company.id, "timer"); } + // Company bootstrap: if goals exist but no active tasks, force-wake the CEO + // This ensures the company starts running even without manual intervention + const companyGoals = snapshot.goals.filter((g) => g.companyId === company.id && (g.status === "active" || g.status === "planned")); + const companyActiveTasks = snapshot.tasks.filter((t) => t.companyId === company.id && !["done", "cancelled"].includes(t.status) && !t.title.startsWith("[Heartbeat]")); + if (companyGoals.length > 0 && companyActiveTasks.length === 0) { + const ceo = snapshot.agents.find((a) => a.companyId === company.id && !a.reportsTo && a.status !== "terminated"); + const ceoLastWake = ceo?.lastHeartbeatAt ? new Date(ceo.lastHeartbeatAt).getTime() : 0; + const bootstrapCooldown = 120_000; // 2 minutes between bootstrap attempts + if (ceo && !hasActiveRun(snapshot, { agentId: ceo.id, companyId: company.id }) && (now - ceoLastWake > bootstrapCooldown)) { + log.info(`[heartbeat] Company ${company.name} has ${companyGoals.length} goals but no tasks — bootstrapping CEO ${ceo.name}`); + startAgentHeartbeatRun(ceo.id, company.id, "bootstrap"); + } + } + + // Rate-limit schedule rules to once per 60 seconds per company + const scheduleKey = `schedule-${company.id}`; + const lastScheduleFired = (scheduleLastFiredMap as Map).get(scheduleKey) ?? 0; + if (now - lastScheduleFired >= 60_000) { + dispatchAutomation("schedule", { companyId: company.id }); + (scheduleLastFiredMap as Map).set(scheduleKey, now); + } + + // Check for idle agents — agents with no tasks and no recent runs + const companyAgents = snapshot.agents.filter((a) => a.companyId === company.id && a.status !== "terminated" && a.status !== "paused" && a.status !== "pending_approval"); + for (const agent of companyAgents) { + const hasAssigned = snapshot.tasks.some( + (t) => t.assigneeAgentId === agent.id && t.companyId === company.id && ["todo", "in_progress", "blocked"].includes(t.status), + ); + if (!hasAssigned) { + const lastActive = agent.lastHeartbeatAt ? new Date(agent.lastHeartbeatAt).getTime() : 0; + const idleThreshold = 20 * 60 * 1000; // 20 minutes (must exceed 2x max heartbeat interval to avoid false positives) + if (now - lastActive > idleThreshold) { + dispatchAutomation("agent_idle", { + companyId: company.id, + agentId: agent.id, + department: agent.department ?? undefined, + }); + } + } + } + + // Monthly budget reset — persisted in database to survive app restarts + const currentMonth = new Date().toISOString().slice(0, 7); // "2026-03" + const lastResetMonth = db.getLastBudgetResetMonth(company.id) ?? ""; + if (currentMonth !== lastResetMonth) { + db.resetMonthlyBudgets(company.id); + db.setLastBudgetResetMonth(company.id, currentMonth); + log.info(`[heartbeat] Reset monthly budgets for company ${company.name} (${currentMonth})`); + } } catch (companyError) { log.error(`[heartbeat] Error processing company ${company.id}: ${companyError instanceof Error ? companyError.message : String(companyError)}`); } } + // Periodic cleanup: release stale workspace locks (held >2h) and expired pending wakes + try { + const staleReleased = db.releaseStaleWorkspaceLocks(); + if (staleReleased > 0) { + log.info(`[heartbeat] Released ${staleReleased} stale workspace lock(s)`); + publishDomainChanged(); + } + } catch (cleanupErr) { + log.error(`[heartbeat] Stale lock cleanup failed: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`); + } } catch (error) { log.error(`[heartbeat] Scheduler error: ${error instanceof Error ? error.message : String(error)}`); } - }, 15_000); // Check every 15 seconds + }, 15_000); +} + +function dispatchRunToWorker(params: { + runId: string; + companyId: string; + agent: ReturnType; + task: ReturnType; + connector: ReturnType; + workspace: ReturnType | null; + workspaceMode: WorkspaceLockMode; + companyName: string; + companyDescription: string; + snapshot: ReturnType; + trigger: string; +}) { + const { runId, companyId, agent, task, connector, workspace, workspaceMode, companyName, companyDescription, snapshot, trigger } = params; + if (!worker) { + throw new Error("Orchestration worker is not ready."); + } + + const env = db.resolveSecretEnv(connector.envBindingText, companyId); + const apiPort = apiServer?.getPort() ?? 0; + const apiUrl = apiPort ? `http://127.0.0.1:${apiPort}` : ""; + const apiKey = apiUrl ? createAgentToken(agent.id, companyId, runId) : ""; + + const assignedTasks = db.listAgentTasks(agent.id, companyId, ["todo", "in_progress", "blocked"]); + const recentComments = snapshot.comments + .filter((c) => assignedTasks.some((t) => t.id === c.taskId)) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, 20) + .map((c) => ({ + taskId: c.taskId, + taskTitle: assignedTasks.find((t) => t.id === c.taskId)?.title ?? c.taskId, + authorName: c.authorName, + body: c.body, + createdAt: c.createdAt, + })); + + const systemPrompt = apiUrl ? buildTaskPrompt({ + agent, + apiUrl, + apiKey, + runId, + task, + companyName, + goals: snapshot.goals.filter((g) => g.companyId === companyId), + projects: snapshot.projects.filter((p) => p.companyId === companyId), + directReports: db.getDirectReports(agent.id, companyId), + chainOfCommand: db.getChainOfCommand(agent.id), + assignedTasks, + allAgents: snapshot.agents.filter((a) => a.companyId === companyId), + pendingApprovals: snapshot.approvals.filter( + (a) => a.companyId === companyId && + a.requestedByAgentId === agent.id && + (a.state === "pending" || a.state === "revision_requested"), + ), + tasksAwaitingReview: db.listReviewTasksForManager(agent.id, companyId), + wakeReason: trigger, + allTasks: snapshot.tasks.filter((t) => t.companyId === companyId), + recentComments, + companyDescription, + socialAccounts: db.listSocialAccounts(companyId).map((a) => ({ + id: a.id, + platform: a.platform, + accountName: a.accountName, + status: a.status, + requireApproval: a.requireApproval, + })), + recentMessages: db.listAgentMessages(companyId, { agentId: agent.id, limit: 15 }), + upcomingMeetings: snapshot.meetings + .filter((m) => m.companyId === companyId && (m.status === "scheduled" || m.status === "in_progress")) + .filter((m) => { + try { + const ids: string[] = JSON.parse(m.participantAgentIds); + return ids.includes(agent.id) || m.organizerAgentId === agent.id; + } catch { + return false; + } + }) + .slice(0, 5), + relevantKnowledge: snapshot.knowledgeBase + .filter((k) => k.companyId === companyId && (k.importance === "critical" || k.importance === "high")) + .slice(0, 8), + activeSprint: snapshot.sprints.find((s) => s.companyId === companyId && s.status === "active") ?? null, + recentDocuments: snapshot.documents + .filter((d) => d.companyId === companyId && (d.authorAgentId === agent.id || d.reviewerAgentId === agent.id || d.status === "in_review")) + .slice(0, 5), + }) : null; + + if (apiUrl) { + env.AGENT_COMPANY_API_URL = apiUrl; + env.AGENT_COMPANY_API_KEY = apiKey; + env.AGENT_COMPANY_AGENT_ID = agent.id; + env.AGENT_COMPANY_COMPANY_ID = companyId; + env.AGENT_COMPANY_RUN_ID = runId; + env.AGENT_COMPANY_TASK_ID = task.id; + env.AGENT_COMPANY_WAKE_REASON = trigger; + } + + const taskSession = db.getTaskSession(task.id); + let thinkingEffort: string | null = null; + try { + const meta = JSON.parse(agent.metadataJson || "{}"); + if (typeof meta.thinkingEffort === "string" && meta.thinkingEffort) { + thinkingEffort = meta.thinkingEffort; + } + } catch { /* malformed metadata */ } + + worker.postMessage({ + type: "enqueue-run", + payload: { + runId, + companyId, + taskId: task.id, + taskTitle: task.title, + taskDescription: task.description, + agentId: agent.id, + agentName: agent.name, + connectorId: connector.id, + workspaceId: workspace?.id ?? null, + workspacePath: workspace?.localPath ?? null, + workspaceMode, + command: connector.command, + model: connector.model ?? agent.model, + thinkingEffort, + env, + sessionParams: taskSession.sessionParams, + systemPrompt, + trigger, + }, + }); } function registerHandlers() { - registerHandle(IPC_CHANNELS.loadSnapshot, async () => ok(db.listSnapshot())); + registerHandle(IPC_CHANNELS.loadSnapshot, () => ok(db.listRendererSnapshot())); registerHandle(IPC_CHANNELS.pickDirectory, async () => { const result = await dialog.showOpenDialog({ @@ -1273,6 +2445,7 @@ function registerHandlers() { name: parsed.name, role: parsed.role, title: parsed.title, + department: parsed.department ?? null, status: parsed.status, reportsTo: parsed.reportsTo ?? null, connectorId: parsed.connectorId, @@ -1319,7 +2492,6 @@ function registerHandlers() { }); publishDomainChanged(); - // Auto-cascade: when a goal becomes active with an owner agent, enable heartbeat and wake immediately if (parsed.status === "active" && parsed.ownerAgentId) { wakeAgentIfPossible(parsed.ownerAgentId, parsed.companyId, "goal_activated"); } @@ -1368,6 +2540,14 @@ function registerHandlers() { if (invalidReference) { return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); } + const isNew = !parsed.id; + let previousStatus: string | null = null; + if (!isNew) { + try { + const existing = db.getTask(parsed.id!); + previousStatus = existing.status; + } catch { /* task is new */ } + } let id: string; try { id = db.saveTask(parsed); @@ -1377,13 +2557,15 @@ function registerHandlers() { db.addActivity({ companyId: parsed.companyId, actor: "board", - action: parsed.id ? "task.updated" : "task.created", + action: isNew ? "task.created" : "task.updated", entityType: "task", entityId: id, detail: parsed.title, }); - if (parsed.assigneeAgentId) { - wakeAgentIfPossible(parsed.assigneeAgentId, parsed.companyId, "assignment"); + if (isNew) { + handleTaskCreated(id, parsed.companyId); + } else if (previousStatus && previousStatus !== parsed.status) { + handleTaskStatusChange(id, parsed.companyId, previousStatus, parsed.status); } publishDomainChanged(); return ok(id); @@ -1418,7 +2600,7 @@ function registerHandlers() { const parsed = approvalDecisionSchema.parse(payload); db.decideApproval(parsed); const snapshot = db.listSnapshot(); - const approval = snapshot.approvals.find((entry) => entry.id === parsed.approvalId); + const approval = snapshot.approvals.find((entry) => entry.id === parsed.approvalId && entry.companyId === parsed.companyId); if (approval) { db.addActivity({ companyId: approval.companyId, @@ -1428,29 +2610,7 @@ function registerHandlers() { entityId: approval.id, detail: parsed.decisionNote, }); - if (parsed.state === "approved" || parsed.state === "revision_requested" || parsed.state === "rejected") { - wakeAgentIfPossible(approval.requestedByAgentId, approval.companyId, "approval_resolved"); - } - - // Step 4: Wake newly hired agent when hire is approved - if (approval.type === "hire_agent" && parsed.state === "approved") { - try { - const fullApproval = db.getApprovalRecord(parsed.approvalId); - const payload = JSON.parse(fullApproval.payloadJson || "{}") as { agentId?: string }; - if (payload.agentId) { - // Don't wake new hires if org-strategy is still pending approval - const hasPendingOrgApproval = snapshot.approvals.some( - (a) => - a.companyId === approval.companyId && - a.type === "approve_ceo_strategy" && - (a.state === "pending" || a.state === "revision_requested"), - ); - if (!hasPendingOrgApproval) { - wakeAgentIfPossible(payload.agentId, approval.companyId, "assignment"); - } - } - } catch { /* ignore if approval payload is malformed */ } - } + handleApprovalResolved(parsed.approvalId, approval.companyId, parsed.state); } publishDomainChanged(); return ok(true); @@ -1482,12 +2642,7 @@ function registerHandlers() { if (!company || company.status !== "active") { return fail("COMPANY_NOT_ACTIVE", "Runs can only start while the company is active."); } - if ( - task.activeRunId || - snapshot.runs.some( - (run) => run.taskId === task.id && run.companyId === task.companyId && (run.status === "queued" || run.status === "running"), - ) - ) { + if (task.activeRunId || hasActiveRun(snapshot, { taskId: task.id, companyId: task.companyId })) { return fail("TASK_ALREADY_RUNNING", "This task already has an active run."); } if (!task.assigneeAgentId) return fail("TASK_UNASSIGNED", "Assign an agent before starting a run."); @@ -1506,15 +2661,10 @@ function registerHandlers() { if (eligibilityFailure === "CONNECTOR_NOT_READY") { return fail("CONNECTOR_NOT_READY", "The assigned agent connector is not ready."); } - if ( - snapshot.runs.some( - (run) => run.companyId === task.companyId && run.agentId === agent.id && (run.status === "queued" || run.status === "running"), - ) - ) { + if (hasActiveRun(snapshot, { agentId: agent.id, companyId: task.companyId })) { return fail("AGENT_ALREADY_RUNNING", "The assigned agent already has an active run."); } - // Phase 3: Approval hard gate — block runs when relevant approvals are pending if (db.isAgentPendingApproval(agent.id)) { return fail("AGENT_PENDING_APPROVAL", "This agent is pending hire approval and cannot run tasks."); } @@ -1526,151 +2676,38 @@ function registerHandlers() { ); } - const workspace = - db.getWorkspace(task.workspaceId) ?? - db.getWorkspace(agent.workspaceId) ?? - db.getProjectPrimaryWorkspace(task.companyId) ?? - db.getAnyCompanyWorkspace(task.companyId); + const workspaceResolution = resolveWorkspace(task, agent, task.companyId); + const workspace = workspaceResolution.workspace; log.info( - `[run] Workspace resolution for ${agent.name}: task.workspaceId=${task.workspaceId ?? "null"}, agent.workspaceId=${agent.workspaceId ?? "null"}, resolved=${workspace?.name ?? "NONE"}, localPath=${workspace?.localPath ?? "NONE"}`, + `[run] Workspace resolution for ${agent.name}: task.workspaceId=${task.workspaceId ?? "null"}, agent.workspaceId=${agent.workspaceId ?? "null"}, projectId=${task.projectId ?? "null"}, source=${workspaceResolution.source}, mode=${workspaceResolution.lockMode}, resolved=${workspace?.name ?? "NONE"}, localPath=${workspace?.localPath ?? "NONE"}`, ); if (workspace && workspace.companyId !== task.companyId) { return fail("WORKSPACE_COMPANY_MISMATCH", "The selected workspace does not belong to the task company."); } - // Check workspace lock BEFORE creating the run — fail if another run holds it - if (workspace && !db.acquireWorkspaceLock(workspace.id, `pre-check-manual-${agent.id}`)) { - return fail("WORKSPACE_LOCKED", `Workspace "${workspace.name}" is locked by another active run. Wait for it to finish or cancel it.`); - } - if (workspace) { - db.releaseWorkspaceLock(workspace.id, `pre-check-manual-${agent.id}`); - } - const run = db.createRun({ companyId: task.companyId, taskId: task.id, agentId: agent.id, - workspaceId: workspace?.id ?? null, + workspaceId: workspaceResolution.lockMode === "exclusive" ? (workspace?.id ?? null) : null, connectorId: connector.id, }); - // Acquire workspace lock with real run ID — if race condition, cancel the run - if (workspace) { - const locked = db.acquireWorkspaceLock(workspace.id, run.id); - if (!locked) { - db.finishRun({ runId: run.id, status: "cancelled", summary: "Workspace lock contention.", errorMessage: null, exitCode: null, signal: null, model: null, sessionDisplayId: null, costUsd: null, unattributedCost: true }); - return fail("WORKSPACE_LOCKED", `Workspace "${workspace.name}" was locked by another run during startup.`); - } - } - - const env = db.resolveSecretEnv(connector.envBindingText, task.companyId); - - // Build rich prompt with API context - const apiPort = apiServer?.getPort() ?? 0; - const apiUrl = apiPort ? `http://127.0.0.1:${apiPort}` : ""; - const apiKey = apiUrl ? createAgentToken(agent.id, task.companyId, run.id) : ""; - - const manualAgentTasks = db.listAgentTasks(agent.id, task.companyId, ["todo", "in_progress", "blocked"]); const manualCompany = snapshot.companies.find((c) => c.id === task.companyId); - const manualRecentComments = snapshot.comments - .filter((c) => manualAgentTasks.some((t) => t.id === c.taskId)) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .slice(0, 20) - .map((c) => ({ - taskId: c.taskId, - taskTitle: manualAgentTasks.find((t) => t.id === c.taskId)?.title ?? c.taskId, - authorName: c.authorName, - body: c.body, - createdAt: c.createdAt, - })); - const systemPrompt = apiUrl ? buildTaskPrompt({ - agent, - apiUrl, - apiKey, + dispatchRunToWorker({ runId: run.id, + companyId: task.companyId, + agent, task, + connector, + workspace, + workspaceMode: workspaceResolution.lockMode, companyName: manualCompany?.name ?? "Company", - goals: snapshot.goals.filter((g) => g.companyId === task.companyId), - projects: snapshot.projects.filter((p) => p.companyId === task.companyId), - directReports: db.getDirectReports(agent.id, task.companyId), - chainOfCommand: db.getChainOfCommand(agent.id), - assignedTasks: manualAgentTasks, - allAgents: snapshot.agents.filter((a) => a.companyId === task.companyId), - pendingApprovals: snapshot.approvals.filter( - (a) => a.companyId === task.companyId && - a.requestedByAgentId === agent.id && - (a.state === "pending" || a.state === "revision_requested"), - ), - tasksAwaitingReview: db.listReviewTasksForManager(agent.id, task.companyId), - wakeReason: "manual", - allTasks: snapshot.tasks.filter((t) => t.companyId === task.companyId), - recentComments: manualRecentComments, companyDescription: manualCompany?.description ?? "", - socialAccounts: db.listSocialAccounts(task.companyId).map(a => ({ - id: a.id, - platform: a.platform, - accountName: a.accountName, - status: a.status, - requireApproval: a.requireApproval, - })), - recentMessages: db.listAgentMessages(task.companyId, { agentId: agent.id, limit: 15 }), - upcomingMeetings: snapshot.meetings - .filter((m: any) => m.companyId === task.companyId && (m.status === "scheduled" || m.status === "in_progress")) - .filter((m: any) => { - try { const ids = JSON.parse(m.participantAgentIds); return ids.includes(agent.id) || m.organizerAgentId === agent.id; } catch { return false; } - }) - .slice(0, 5), - relevantKnowledge: snapshot.knowledgeBase - .filter((k: any) => k.companyId === task.companyId && (k.importance === "critical" || k.importance === "high")) - .slice(0, 8), - activeSprint: snapshot.sprints.find((s: any) => s.companyId === task.companyId && s.status === "active") ?? null, - recentDocuments: snapshot.documents - .filter((d: any) => d.companyId === task.companyId && (d.authorAgentId === agent.id || d.reviewerAgentId === agent.id || d.status === "in_review")) - .slice(0, 5), - }) : null; - - // Add API credentials to env - if (apiUrl) { - env.AGENT_COMPANY_API_URL = apiUrl; - env.AGENT_COMPANY_API_KEY = apiKey; - env.AGENT_COMPANY_AGENT_ID = agent.id; - env.AGENT_COMPANY_COMPANY_ID = task.companyId; - env.AGENT_COMPANY_RUN_ID = run.id; - env.AGENT_COMPANY_TASK_ID = task.id; - env.AGENT_COMPANY_WAKE_REASON = "manual"; - } - - const session = db.getTaskSession(task.id); - let thinkingEffort: string | null = null; - try { - const meta = JSON.parse(agent.metadataJson || "{}"); - if (typeof meta.thinkingEffort === "string" && meta.thinkingEffort) { - thinkingEffort = meta.thinkingEffort; - } - } catch { /* ignore malformed json */ } - worker.postMessage({ - type: "enqueue-run", - payload: { - runId: run.id, - companyId: task.companyId, - taskId: task.id, - taskTitle: task.title, - taskDescription: task.description, - agentId: agent.id, - agentName: agent.name, - connectorId: connector.id, - workspaceId: workspace?.id ?? null, - workspacePath: workspace?.localPath ?? null, - command: connector.command, - model: connector.model ?? agent.model, - thinkingEffort, - env, - sessionParams: session.sessionParams, - systemPrompt, - trigger: "manual", - }, + snapshot, + trigger: "manual", }); db.addActivity({ companyId: task.companyId, @@ -1684,13 +2721,13 @@ function registerHandlers() { return ok(run.id); }); - registerHandle(IPC_CHANNELS.cancelRun, async (_event, payload) => { + registerHandle(IPC_CHANNELS.cancelRun, (_event, payload) => { const parsed = cancelRunSchema.parse(payload); worker?.postMessage({ type: "cancel-run", runId: parsed.runId }); return ok(true); }); - registerHandle(IPC_CHANNELS.getRunLog, async (_event, payload) => { + registerHandle(IPC_CHANNELS.getRunLog, (_event, payload) => { const parsed = runLogSchema.parse(payload); return ok(db.readRunLog(parsed.runId)); }); @@ -1706,6 +2743,10 @@ function registerHandlers() { }); registerHandle(IPC_CHANNELS.restoreProfile, async () => { + const hasActiveRuns = db.listSnapshot().runs.some((run) => run.status === "queued" || run.status === "running"); + if (hasActiveRuns) { + return fail("RESTORE_BLOCKED", "Stop all active runs before restoring a profile backup."); + } const result = await dialog.showOpenDialog({ properties: ["openDirectory"], }); @@ -1739,96 +2780,34 @@ function registerHandlers() { return ok(true); }); - registerHandle(IPC_CHANNELS.deleteAgent, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany("agents", parsed.id, parsed.companyId)) { - return fail("AGENT_NOT_FOUND", "Agent not found in the current company."); - } - db.deleteAgent(parsed.id, parsed.companyId); - db.addActivity({ companyId: parsed.companyId, actor: "board", action: "agent.deleted", entityType: "agent", entityId: parsed.id, detail: "" }); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.deleteTask, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany("tasks", parsed.id, parsed.companyId)) { - return fail("TASK_NOT_FOUND", "Task not found in the current company."); - } - db.deleteTask(parsed.id, parsed.companyId); - db.addActivity({ companyId: parsed.companyId, actor: "board", action: "task.deleted", entityType: "task", entityId: parsed.id, detail: "" }); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.deleteGoal, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany("goals", parsed.id, parsed.companyId)) { - return fail("GOAL_NOT_FOUND", "Goal not found in the current company."); - } - db.deleteGoal(parsed.id, parsed.companyId); - db.addActivity({ companyId: parsed.companyId, actor: "board", action: "goal.deleted", entityType: "goal", entityId: parsed.id, detail: "" }); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.deleteProject, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany("projects", parsed.id, parsed.companyId)) { - return fail("PROJECT_NOT_FOUND", "Project not found in the current company."); - } - db.deleteProject(parsed.id, parsed.companyId); - db.addActivity({ companyId: parsed.companyId, actor: "board", action: "project.deleted", entityType: "project", entityId: parsed.id, detail: "" }); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.deleteWorkspace, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany("workspaces", parsed.id, parsed.companyId)) { - return fail("WORKSPACE_NOT_FOUND", "Workspace not found in the current company."); - } - db.deleteWorkspace(parsed.id, parsed.companyId); - db.addActivity({ companyId: parsed.companyId, actor: "board", action: "workspace.deleted", entityType: "workspace", entityId: parsed.id, detail: "" }); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.deleteApproval, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany("approvals", parsed.id, parsed.companyId)) { - return fail("APPROVAL_NOT_FOUND", "Approval not found in the current company."); - } - db.deleteApproval(parsed.id, parsed.companyId); - db.addActivity({ companyId: parsed.companyId, actor: "board", action: "approval.deleted", entityType: "approval", entityId: parsed.id, detail: "" }); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.deleteSecret, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany("secrets", parsed.id, parsed.companyId)) { - return fail("SECRET_NOT_FOUND", "Secret not found in the current company."); - } - db.deleteSecret(parsed.id, parsed.companyId); - db.addActivity({ companyId: parsed.companyId, actor: "board", action: "secret.deleted", entityType: "secret", entityId: parsed.id, detail: "" }); - publishDomainChanged(); - return ok(true); - }); + const deleteHandlers: Array<{ + channel: string; + table: CompanyScopedTable; + entity: string; + deleteFn: (id: string, companyId: string) => void; + }> = [ + { channel: IPC_CHANNELS.deleteAgent, table: "agents", entity: "agent", deleteFn: (id, cid) => db.deleteAgent(id, cid) }, + { channel: IPC_CHANNELS.deleteTask, table: "tasks", entity: "task", deleteFn: (id, cid) => db.deleteTask(id, cid) }, + { channel: IPC_CHANNELS.deleteGoal, table: "goals", entity: "goal", deleteFn: (id, cid) => db.deleteGoal(id, cid) }, + { channel: IPC_CHANNELS.deleteProject, table: "projects", entity: "project", deleteFn: (id, cid) => db.deleteProject(id, cid) }, + { channel: IPC_CHANNELS.deleteWorkspace, table: "workspaces", entity: "workspace", deleteFn: (id, cid) => db.deleteWorkspace(id, cid) }, + { channel: IPC_CHANNELS.deleteApproval, table: "approvals", entity: "approval", deleteFn: (id, cid) => db.deleteApproval(id, cid) }, + { channel: IPC_CHANNELS.deleteSecret, table: "secrets", entity: "secret", deleteFn: (id, cid) => db.deleteSecret(id, cid) }, + ]; + for (const { channel, table, entity, deleteFn } of deleteHandlers) { + registerHandle(channel, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany(table, parsed.id, parsed.companyId)) { + return fail(`${entity.toUpperCase()}_NOT_FOUND`, `${entity.charAt(0).toUpperCase() + entity.slice(1)} not found in the current company.`); + } + deleteFn(parsed.id, parsed.companyId); + db.addActivity({ companyId: parsed.companyId, actor: "board", action: `${entity}.deleted`, entityType: entity, entityId: parsed.id, detail: "" }); + publishDomainChanged(); + return ok(true); + }); + } registerHandle(IPC_CHANNELS.getSidebarBadges, async (_event, companyId) => { let parsedCompanyId: string; @@ -1840,8 +2819,6 @@ function registerHandlers() { return ok(db.getSidebarBadges(parsedCompanyId)); }); - /* ─── Comments ────────────────────────────────────────────────────── */ - registerHandle(IPC_CHANNELS.addComment, async (_event, payload) => { const parsed = commentInputSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); @@ -1864,12 +2841,16 @@ function registerHandlers() { emitEvent({ type: "agent-message", taskId: parsed.taskId, comment }); publishDomainChanged(); - // Step 5c: Wake task assignee when Board/UI posts a comment try { const task = db.getTask(parsed.taskId); if (task.assigneeAgentId) { wakeAgentIfPossible(task.assigneeAgentId, task.companyId, "comment"); } + dispatchAutomation("comment_posted", { + companyId: parsed.companyId, + taskId: parsed.taskId, + agentId: parsed.authorAgentId ?? undefined, + }); } catch { /* ignore if task not found */ } return ok(comment.id); @@ -1880,8 +2861,6 @@ function registerHandlers() { return ok(db.listComments(parsed.taskId)); }); - /* ─── Heartbeat ──────────────────────────────────────────────────── */ - registerHandle(IPC_CHANNELS.setHeartbeat, async (_event, payload) => { const parsed = heartbeatSettingsSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); @@ -1915,7 +2894,7 @@ function registerHandlers() { return ok(runId); }); - registerHandle(IPC_CHANNELS.getApiPort, async () => { + registerHandle(IPC_CHANNELS.getApiPort, () => { return ok(apiServer?.getPort() ?? 0); }); @@ -1933,32 +2912,22 @@ function registerHandlers() { } }); - // ── Inbox ────────────────────────────────────────────────────────── - registerHandle(IPC_CHANNELS.getInbox, (_event, ...args) => { - const companyId = companyIdSchema.parse(args[0]); + registerHandle(IPC_CHANNELS.getInbox, (_event, payload) => { + const companyId = companyIdSchema.parse(payload); const err = ensureCompanyExists(companyId); if (err) return err; return ok(db.getInbox(companyId)); }); - registerHandle(IPC_CHANNELS.dismissInboxItem, (_event, ...args) => { - const input = inboxDismissSchema.parse(args[0]); - const err = ensureCompanyExists(input.companyId); - if (err) return err; - // Dismiss is a no-op for now (items are computed on the fly) - return ok(true); - }); - - // ── Metrics ──────────────────────────────────────────────────────── - registerHandle(IPC_CHANNELS.getCompanyMetrics, (_event, ...args) => { - const input = metricsQuerySchema.parse(args[0]); + registerHandle(IPC_CHANNELS.getCompanyMetrics, (_event, payload) => { + const input = metricsQuerySchema.parse(payload); const err = ensureCompanyExists(input.companyId); if (err) return err; return ok(db.getCompanyMetrics(input.companyId, input.days)); }); - registerHandle(IPC_CHANNELS.getAgentMetrics, (_event, ...args) => { - const input = metricsQuerySchema.parse(args[0]); + registerHandle(IPC_CHANNELS.getAgentMetrics, (_event, payload) => { + const input = metricsQuerySchema.parse(payload); const err = ensureCompanyExists(input.companyId); if (err) return err; if (!input.agentId) return fail("MISSING_AGENT_ID", "agentId is required"); @@ -1968,25 +2937,20 @@ function registerHandlers() { return ok(agentMetric); }); - // ── Standup ──────────────────────────────────────────────────────── - registerHandle(IPC_CHANNELS.getStandupReport, (_event, ...args) => { - const companyId = companyIdSchema.parse(args[0]); + registerHandle(IPC_CHANNELS.getStandupReport, (_event, payload) => { + const companyId = companyIdSchema.parse(payload); const err = ensureCompanyExists(companyId); if (err) return err; return ok(db.getStandupReport(companyId)); }); - // ── Templates ────────────────────────────────────────────────────── - registerHandle(IPC_CHANNELS.listCompanyTemplates, () => { - return ok(COMPANY_TEMPLATES); - }); + registerHandle(IPC_CHANNELS.listCompanyTemplates, () => ok(COMPANY_TEMPLATES)); - registerHandle(IPC_CHANNELS.applyCompanyTemplate, (_event, ...args) => { - const input = templateApplySchema.parse(args[0]); + registerHandle(IPC_CHANNELS.applyCompanyTemplate, (_event, payload) => { + const input = templateApplySchema.parse(payload); const template = getTemplate(input.templateId); if (!template) return fail("TEMPLATE_NOT_FOUND", "Company template not found"); - // Create the company const companyId = db.saveCompany({ name: input.companyName, description: input.companyDescription || template.description, @@ -1994,7 +2958,6 @@ function registerHandlers() { autoApproveHires: true, }); - // Create agents in order (so we can resolve reportsTo by index) const agentIds: string[] = []; const connectors = db.listSnapshot().connectors; const readyConnectors = connectors.filter(c => c.status === "ready"); @@ -2024,7 +2987,6 @@ function registerHandlers() { agentIds.push(agentId); } - // Create goals owned by the CEO (first agent) const ceoId = agentIds[0]; for (const goalDef of template.goals) { db.saveGoal({ @@ -2037,15 +2999,12 @@ function registerHandlers() { }); } - // Set current company db.setCurrentCompany(companyId); - // Enable heartbeat for CEO to kick off autonomous operation if (ceoId) { - db.setHeartbeat(ceoId, true, 120); + db.setHeartbeat(ceoId, true, 60); } - // Activity log db.addActivity({ companyId, actor: "board", @@ -2055,78 +3014,202 @@ function registerHandlers() { detail: `Created from template: ${template.name}`, }); - emitEvent({ type: "domain-changed" }); + const defaultRules: Array<{ name: string; description: string; trigger: string; action: string; conditionsJson?: string; actionConfigJson?: string; priority?: number }> = [ + { + name: "Auto-assign new tasks", + description: "Automatically assign new unassigned tasks to the best-fit agent based on capabilities and workload.", + trigger: "task_created", + action: "assign_task", + priority: 90, + }, + { + name: "Escalate blocked tasks", + description: "When a task is blocked, escalate to the assignee's manager.", + trigger: "task_blocked", + action: "escalate_to_manager", + actionConfigJson: JSON.stringify({ body: "A task assigned to your report is blocked and needs your attention." }), + priority: 85, + }, + { + name: "Escalate completed work to manager", + description: "When a run completes successfully, escalate to the assignee's manager so they review the work.", + trigger: "run_completed", + action: "escalate_to_manager", + actionConfigJson: JSON.stringify({ body: "A task run has completed successfully. Please review the work and approve or send back for changes." }), + priority: 80, + }, + { + name: "Reassign on run failure", + description: "When a run fails, attempt to reassign the task to another capable agent.", + trigger: "run_failed", + action: "reassign_task", + priority: 75, + }, + { + name: "Budget alert at 80%", + description: "Send an alert when an agent reaches 80% of their monthly budget.", + trigger: "budget_threshold", + action: "send_message", + actionConfigJson: JSON.stringify({ subject: "Budget warning", body: "Agent is approaching budget limit (80% utilized).", priority: "urgent" }), + priority: 70, + }, + { + name: "Notify department on task completion", + description: "When a run completes successfully, send a status update to the agent's department channel.", + trigger: "run_completed", + action: "send_message", + actionConfigJson: JSON.stringify({ channel: "department", subject: "Task completed", body: "A task has been completed successfully. Check the task details for more information.", priority: "low" }), + priority: 40, + }, + { + name: "Wake new hire on approval", + description: "When a new agent hire is approved, immediately wake the new agent to start onboarding.", + trigger: "hire_approved", + action: "trigger_heartbeat", + actionConfigJson: JSON.stringify({}), + priority: 90, + }, + { + name: "Auto-run assigned tasks", + description: "When a task transitions to 'todo' status, automatically wake the assigned agent to start working on it.", + trigger: "task_status_changed", + conditionsJson: JSON.stringify({ status: "todo" }), + action: "run_task", + actionConfigJson: JSON.stringify({}), + priority: 65, + }, + { + name: "Notify company on sprint start", + description: "When a sprint starts, notify the entire company.", + trigger: "sprint_started", + action: "send_message", + actionConfigJson: JSON.stringify({ channel: "company", subject: "Sprint started", body: "A new sprint has begun. Check your assigned tasks and start execution.", priority: "normal" }), + priority: 50, + }, + { + name: "Schedule retrospective on sprint end", + description: "When a sprint ends, schedule a retrospective meeting.", + trigger: "sprint_ended", + action: "schedule_meeting", + actionConfigJson: JSON.stringify({ meetingType: "retrospective", title: "Sprint Retrospective", durationMinutes: 45 }), + priority: 55, + }, + { + name: "Create status report on schedule", + description: "Periodically create a status report document for the company.", + trigger: "schedule", + action: "create_document", + actionConfigJson: JSON.stringify({ documentType: "status_report", title: "Automated Status Report" }), + priority: 30, + }, + { + name: "Auto-assign work to idle agents", + description: "When an agent has been idle for 10+ minutes, automatically assign available unassigned tasks from the backlog.", + trigger: "agent_idle", + action: "assign_task", + priority: 60, + }, + { + name: "Wake idle agents to check for work", + description: "When an agent has no tasks and is idle, wake them so they can check for available work and coordinate with their team.", + trigger: "agent_idle", + action: "trigger_heartbeat", + priority: 45, + }, + ]; + for (const rule of defaultRules) { + db.saveAutomationRule({ + companyId, + name: rule.name, + description: rule.description, + trigger: rule.trigger, + conditionsJson: rule.conditionsJson ?? "{}", + action: rule.action, + actionConfigJson: rule.actionConfigJson ?? "{}", + priority: rule.priority ?? 50, + status: "active", + }); + } + + for (const agentId of agentIds) { + if (agentId !== ceoId) { + db.setHeartbeat(agentId, true, 120); // 2 min interval for non-CEO + } + } + + // Generate initial company messages so channels are populated from day one + generateInitialCompanyMessages(companyId, template, agentIds); + + publishDomainChanged(); return ok(companyId); }); - // ── Social Accounts ──────────────────────────────────────────────── - registerHandle(IPC_CHANNELS.saveSocialAccount, (_event, ...args) => { - const input = socialAccountInputSchema.parse(args[0]); + registerHandle(IPC_CHANNELS.saveSocialAccount, (_event, payload) => { + const input = socialAccountInputSchema.parse(payload); const err = ensureCompanyExists(input.companyId); if (err) return err; const id = db.saveSocialAccount(input); - emitEvent({ type: "domain-changed" }); + publishDomainChanged(); return ok(id); }); - registerHandle(IPC_CHANNELS.deleteSocialAccount, (_event, ...args) => { - const input = deleteEntitySchema.parse(args[0]); + registerHandle(IPC_CHANNELS.deleteSocialAccount, (_event, payload) => { + const input = deleteEntitySchema.parse(payload); const err = ensureCompanyExists(input.companyId); if (err) return err; db.deleteSocialAccount(input.id, input.companyId); - emitEvent({ type: "domain-changed" }); + publishDomainChanged(); return ok(true); }); - registerHandle(IPC_CHANNELS.listBrowserActions, (_event, ...args) => { - const input = browserActionQuerySchema.parse(args[0]); + registerHandle(IPC_CHANNELS.listBrowserActions, (_event, payload) => { + const input = browserActionQuerySchema.parse(payload); const err = ensureCompanyExists(input.companyId); if (err) return err; - const actions = db.listBrowserActions(input.companyId, { + return ok(db.listBrowserActions(input.companyId, { socialAccountId: input.socialAccountId, status: input.status, limit: input.limit, - }); - return ok(actions); + })); }); - registerHandle(IPC_CHANNELS.cancelBrowserAction, (_event, ...args) => { - const input = cancelRunSchema.parse(args[0]); + registerHandle(IPC_CHANNELS.cancelBrowserAction, (_event, payload) => { + const input = cancelRunSchema.parse(payload); db.updateBrowserAction(input.runId, { status: "cancelled" }); - emitEvent({ type: "domain-changed" }); + publishDomainChanged(); return ok(true); }); - registerHandle(IPC_CHANNELS.triggerBrowserLogin, async (_event, ...args) => { - const socialAccountId = z.string().uuid().parse(args[0]); + registerHandle(IPC_CHANNELS.triggerBrowserLogin, async (_event, payload) => { + const socialAccountId = z.string().uuid().parse(payload); if (!browserManager) { return fail("BROWSER_NOT_READY", "Browser manager not initialized"); } - // Find the social account to get its platform - const snapshot = db.listSnapshot(); - const account = snapshot.socialAccounts.find(a => a.id === socialAccountId); + const account = db.getSocialAccount(socialAccountId, db.getCurrentCompanyId() ?? randomFallbackCompanyId()); if (!account) { return fail("ACCOUNT_NOT_FOUND", "Social account not found"); } - // Launch login browser (this runs async - the browser stays open for user to login) - // We run it in background and don't block the IPC response browserManager.launchLoginBrowser(socialAccountId, account.platform).then(result => { if (result.success) { + const latestAccount = db.getSocialAccount(socialAccountId, account.companyId); + if (!latestAccount) { + return; + } db.saveSocialAccount({ - id: account.id, - companyId: account.companyId, - platform: account.platform, - accountName: account.accountName, - displayName: account.displayName, - profileUrl: account.profileUrl, - credentialSecretId: account.credentialSecretId, + id: latestAccount.id, + companyId: latestAccount.companyId, + platform: latestAccount.platform, + accountName: latestAccount.accountName, + displayName: latestAccount.displayName, + profileUrl: latestAccount.profileUrl, + credentialSecretId: latestAccount.credentialSecretId, status: "logged_in", - requireApproval: account.requireApproval, + requireApproval: latestAccount.requireApproval, }); - emitEvent({ type: "domain-changed" }); + publishDomainChanged(); } }).catch(err => { log.error(`[browser] Login failed: ${err}`); @@ -2135,95 +3218,104 @@ function registerHandlers() { return ok(true); }); - // ── Meetings ────────────────────────────────────────────────────── registerHandle(IPC_CHANNELS.saveMeeting, async (_event, payload) => { const parsed = meetingInputSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; const id = db.saveMeeting(parsed); - emitEvent({ type: "domain-changed" }); + publishDomainChanged(); return ok(id); }); - registerHandle(IPC_CHANNELS.deleteMeeting, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - db.deleteMeeting(parsed.id, parsed.companyId); - emitEvent({ type: "domain-changed" }); - return ok(true); - }); + const simpleDeleteHandlers: Array<{ channel: string; table: Parameters[0]; entity: string; deleteFn: (id: string, companyId: string) => void }> = [ + { channel: IPC_CHANNELS.deleteMeeting, table: "meetings", entity: "meeting", deleteFn: (id, cid) => db.deleteMeeting(id, cid) }, + { channel: IPC_CHANNELS.deleteDocument, table: "documents", entity: "document", deleteFn: (id, cid) => db.deleteDocument(id, cid) }, + { channel: IPC_CHANNELS.deleteKnowledgeEntry, table: "knowledge_base", entity: "knowledge entry", deleteFn: (id, cid) => db.deleteKnowledgeEntry(id, cid) }, + { channel: IPC_CHANNELS.deleteSprint, table: "sprints", entity: "sprint", deleteFn: (id, cid) => db.deleteSprint(id, cid) }, + { channel: IPC_CHANNELS.deleteAutomationRule, table: "automation_rules", entity: "automation rule", deleteFn: (id, cid) => db.deleteAutomationRule(id, cid) }, + { channel: IPC_CHANNELS.deleteWorkflow, table: "workflow_pipelines", entity: "workflow", deleteFn: (id, cid) => db.deleteWorkflow(id, cid) }, + ]; + for (const { channel, table, entity, deleteFn } of simpleDeleteHandlers) { + registerHandle(channel, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany(table, parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", `${entity.charAt(0).toUpperCase() + entity.slice(1)} not found in the current company.`); + } + deleteFn(parsed.id, parsed.companyId); + publishDomainChanged(); + return ok(true); + }); + } - // ── Documents ───────────────────────────────────────────────────── registerHandle(IPC_CHANNELS.saveDocument, async (_event, payload) => { const parsed = documentInputSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; + const isNew = !parsed.id; const id = db.saveDocument(parsed); - emitEvent({ type: "domain-changed" }); + if (isNew) { + dispatchAutomation("document_created", { + companyId: parsed.companyId, + agentId: parsed.authorAgentId ?? undefined, + projectId: parsed.projectId ?? undefined, + }); + // Wake document reviewer if specified + if (parsed.authorAgentId && parsed.reviewerAgentId) { + wakeAgentIfPossible(parsed.reviewerAgentId, parsed.companyId, "assignment"); + } + } + publishDomainChanged(); return ok(id); }); - registerHandle(IPC_CHANNELS.deleteDocument, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - db.deleteDocument(parsed.id, parsed.companyId); - emitEvent({ type: "domain-changed" }); - return ok(true); - }); - - // ── Knowledge Base ──────────────────────────────────────────────── registerHandle(IPC_CHANNELS.saveKnowledgeEntry, async (_event, payload) => { const parsed = knowledgeEntryInputSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; const id = db.saveKnowledgeEntry(parsed); - emitEvent({ type: "domain-changed" }); + publishDomainChanged(); return ok(id); }); - registerHandle(IPC_CHANNELS.deleteKnowledgeEntry, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - db.deleteKnowledgeEntry(parsed.id, parsed.companyId); - emitEvent({ type: "domain-changed" }); - return ok(true); - }); - - // ── Sprints ─────────────────────────────────────────────────────── registerHandle(IPC_CHANNELS.saveSprint, async (_event, payload) => { const parsed = sprintInputSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; + let previousSprintStatus: string | null = null; + if (parsed.id) { + try { + const existing = db.listSnapshot().sprints.find(s => s.id === parsed.id); + previousSprintStatus = existing?.status ?? null; + } catch { /* sprint is new */ } + } const id = db.saveSprint(parsed); - emitEvent({ type: "domain-changed" }); + // Fire sprint_started when transitioning to active (not just on new sprints) + if (parsed.status === "active" && previousSprintStatus !== "active") { + dispatchAutomation("sprint_started", { companyId: parsed.companyId }); + } + // Fire sprint_ended when transitioning to completed + if (parsed.status === "completed" && previousSprintStatus !== "completed") { + dispatchAutomation("sprint_ended", { companyId: parsed.companyId }); + } + publishDomainChanged(); return ok(id); }); - registerHandle(IPC_CHANNELS.deleteSprint, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - db.deleteSprint(parsed.id, parsed.companyId); - emitEvent({ type: "domain-changed" }); - return ok(true); - }); - - // ── Automation Rules ─────────────────────────────────────────────── registerHandle(IPC_CHANNELS.saveAutomationRule, async (_event, payload) => { const parsed = automationRuleInputSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; const id = db.saveAutomationRule(parsed); - emitEvent({ type: "domain-changed" }); + publishDomainChanged(); return ok(id); }); - registerHandle(IPC_CHANNELS.deleteAutomationRule, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - db.deleteAutomationRule(parsed.id, parsed.companyId); - emitEvent({ type: "domain-changed" }); - return ok(true); - }); - registerHandle(IPC_CHANNELS.toggleAutomationRule, async (_event, payload) => { - const parsed = z.object({ id: z.string().uuid(), companyId: z.string().uuid(), status: z.enum(["active", "paused"]) }).parse(payload); + const parsed = toggleAutomationRuleSchema.parse(payload); db.toggleAutomationRule(parsed.id, parsed.companyId, parsed.status); - emitEvent({ type: "domain-changed" }); + publishDomainChanged(); return ok(true); }); @@ -2232,64 +3324,88 @@ function registerHandlers() { return ok(db.getAutomationLog(parsed)); }); - // ── Agent Messages ───────────────────────────────────────────────── registerHandle(IPC_CHANNELS.sendAgentMessage, async (_event, payload) => { const parsed = agentMessageInputSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; + if (parsed.channel === "direct" && !parsed.toAgentId) { + return fail("DIRECT_MESSAGE_RECIPIENT_REQUIRED", "Direct messages require a recipient agent."); + } + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.toAgentId, table: "agents", label: "message recipient" }, + { id: parsed.channel === "project" ? parsed.channelTargetId : null, table: "projects", label: "project channel" }, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } const id = db.sendAgentMessage(parsed); - // Notify recipient via event const msg = db.listAgentMessages(parsed.companyId, { limit: 1 })?.[0]; if (msg) emitEvent({ type: "new-message", message: msg }); - emitEvent({ type: "domain-changed" }); + publishDomainChanged(); + // Wake the recipient agent so they can respond + if (parsed.toAgentId) { + wakeAgentIfPossible(parsed.toAgentId, parsed.companyId, "message"); + } + // For department messages, wake agents in the department + if (parsed.channel === "department" && parsed.channelTargetId) { + const deptAgents = db.getIdleAgentsForDepartment(parsed.companyId, parsed.channelTargetId); + for (const agent of deptAgents.filter((a) => a.id !== parsed.fromAgentId).slice(0, 3)) { + wakeAgentIfPossible(agent.id, parsed.companyId, "message"); + } + } + // For company-wide messages, wake the CEO so leadership responds + if (parsed.channel === "company") { + const snapshot = db.listSnapshot(); + const ceo = snapshot.agents.find((a) => a.companyId === parsed.companyId && !a.reportsTo && a.status !== "terminated"); + if (ceo && ceo.id !== parsed.fromAgentId) { + wakeAgentIfPossible(ceo.id, parsed.companyId, "message"); + } + } return ok(id); }); registerHandle(IPC_CHANNELS.listAgentMessages, async (_event, payload) => { const parsed = messageQuerySchema.parse(payload); - return ok(db.listAgentMessages(parsed.companyId, parsed)); + return ok(db.listAgentMessages(parsed.companyId, { + agentId: parsed.agentId, + channel: parsed.channel, + channelTargetId: parsed.channelTargetId, + unreadOnly: parsed.unreadOnly, + limit: parsed.limit, + })); }); registerHandle(IPC_CHANNELS.markMessageRead, async (_event, payload) => { - const parsed = z.object({ messageId: z.string().uuid(), companyId: z.string().uuid() }).parse(payload); + const parsed = markMessageReadSchema.parse(payload); db.markMessageRead(parsed.messageId, parsed.companyId); return ok(true); }); - // ── Workflows ────────────────────────────────────────────────────── registerHandle(IPC_CHANNELS.saveWorkflow, async (_event, payload) => { const parsed = workflowInputSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; const id = db.saveWorkflow(parsed); - emitEvent({ type: "domain-changed" }); + publishDomainChanged(); return ok(id); }); - registerHandle(IPC_CHANNELS.deleteWorkflow, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - db.deleteWorkflow(parsed.id, parsed.companyId); - emitEvent({ type: "domain-changed" }); - return ok(true); - }); - registerHandle(IPC_CHANNELS.runWorkflow, async (_event, payload) => { - const parsed = z.object({ id: z.string().uuid(), companyId: z.string().uuid() }).parse(payload); + const parsed = runWorkflowSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; - db.activateWorkflow(parsed.id, parsed.companyId); - emitEvent({ type: "domain-changed" }); + await runWorkflowPipeline(parsed.id, parsed.companyId); + publishDomainChanged(); return ok(true); }); - // ── Auto-Assign ──────────────────────────────────────────────────── registerHandle(IPC_CHANNELS.autoAssignTask, async (_event, payload) => { const parsed = autoAssignSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; const agentId = db.autoAssignTask(parsed.taskId, parsed.companyId); if (!agentId) return fail("NO_AVAILABLE_AGENT", "No available agent could be found for this task."); - emitEvent({ type: "domain-changed" }); + publishDomainChanged(); return ok(agentId); }); } @@ -2314,9 +3430,7 @@ async function bootstrap() { await app.whenReady(); log.initialize(); - // Resolve the user's full shell PATH for packaged apps. - // The static PATH fix above covers common directories, but the login shell - // may have additional paths from .zshrc, .bashrc, nvm, pyenv, etc. + // Resolve additional PATH entries from the user's login shell if (process.platform !== "win32") { try { const userShell = process.env.SHELL || "/bin/zsh"; @@ -2340,12 +3454,9 @@ async function bootstrap() { log.info(`[path] Added ${toAdd.length} shell PATH entries.`); } } - } catch { - // Non-fatal — static PATH fix is already in place - } + } catch { /* non-fatal */ } } - // Set app icon for dock/taskbar (applies even during dev) const appIcon = nativeImage.createFromPath(ICON_PATH); if (!appIcon.isEmpty()) { if (process.platform === "darwin" && app.dock) { @@ -2361,53 +3472,39 @@ async function bootstrap() { db = new AppDatabase(process.env.AGENTCOMPANY_PROFILE_DIR ?? join(app.getPath("userData"), "profile")); db.init(); buildMenu(); + createTray(); createWorker(); registerHandlers(); - // Start local API server for agent-to-system interaction apiServer = new AgentApiServer(db, publishDomainChanged, (agentId, companyId, trigger) => { - wakeAgentIfPossible(agentId, companyId, trigger); + return wakeAgentIfPossible(agentId, companyId, trigger); + }, { + onDomainChanged: publishDomainChanged, + onWakeAgent: (agentId, companyId, trigger) => wakeAgentIfPossible(agentId, companyId, trigger), + onTaskCreated: (taskId, companyId) => handleTaskCreated(taskId, companyId), + onTaskStatusChanged: (taskId, companyId, prev, next) => handleTaskStatusChange(taskId, companyId, prev, next), + onCommentPosted: (taskId, companyId, authorAgentId) => { + dispatchAutomation("comment_posted", { companyId, taskId, agentId: authorAgentId ?? undefined }); + }, + onApprovalResolved: (approvalId, companyId, decision) => handleApprovalResolved(approvalId, companyId, decision), + onDocumentCreated: (companyId, authorAgentId, projectId) => { + dispatchAutomation("document_created", { companyId, agentId: authorAgentId ?? undefined, projectId: projectId ?? undefined }); + }, + onAutoAssignTask: (taskId, companyId) => db.autoAssignTask(taskId, companyId), }); await apiServer.start(); - // Initialize browser manager for social media automation browserManager = new BrowserManager(join(app.getPath("userData"), "browser-data")); - // Wire browser action handler if (apiServer && browserManager) { - const bm = browserManager; - apiServer.setBrowserActionHandler(async (actionId, socialAccountId, actionType, payload) => { - try { - db.updateBrowserAction(actionId, { status: "running", startedAt: new Date().toISOString() }); - emitEvent({ type: "domain-changed" }); - - const result = await bm.executeAction({ - id: actionId, - socialAccountId, - actionType, - payload, - }); - - db.updateBrowserAction(actionId, { - status: result.success ? "succeeded" : "failed", - resultSummary: result.summary, - screenshotPath: result.screenshotPath, - errorMessage: result.error, - finishedAt: new Date().toISOString(), - }); - emitEvent({ type: "domain-changed" }); - } catch (error) { - db.updateBrowserAction(actionId, { - status: "failed", - errorMessage: error instanceof Error ? error.message : String(error), - finishedAt: new Date().toISOString(), - }); - emitEvent({ type: "domain-changed" }); - } + apiServer.setBrowserActionHandler(async (actionId, companyId, socialAccountId, actionType, payload) => { + void socialAccountId; + void actionType; + void payload; + await dispatchBrowserAction(actionId, companyId); }); } - // Start heartbeat scheduler for continuous agent operation startHeartbeatScheduler(); await createWindow(); @@ -2451,9 +3548,7 @@ app.on("second-instance", () => { mainWindow.focus(); }); -app.on("window-all-closed", () => { - // Keep the process alive so scheduled automation and background runs continue. -}); +app.on("window-all-closed", () => {}); app.on("before-quit", () => { isQuitting = true; @@ -2463,6 +3558,8 @@ app.on("before-quit", () => { apiServer = null; browserManager?.shutdown(); browserManager = null; + appTray?.destroy(); + appTray = null; worker?.kill(); worker = null; db?.close(); diff --git a/src/main/orchestrator/worker.ts b/src/main/orchestrator/worker.ts index 2c445245..23198e63 100644 --- a/src/main/orchestrator/worker.ts +++ b/src/main/orchestrator/worker.ts @@ -2,37 +2,19 @@ import { runningProcesses } from "@agentcompany/adapter-utils/server-utils"; import { getConnectorDefinition } from "@main/connectors"; import type { ConnectorId, RunStatus } from "@shared/types"; -/** - * Environment variables allowed to propagate from the host process to connector - * child processes. Any env var NOT in this list is stripped. Secret bindings - * resolved from the vault are always injected regardless of this allowlist. - */ const ENV_ALLOWLIST: ReadonlySet = new Set([ - // PATH is required for command resolution "PATH", - // Basic locale / terminal "HOME", "USER", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TMPDIR", - // macOS-specific "DEVELOPER_DIR", "SDKROOT", - // Node "NODE_ENV", "NODE_OPTIONS", - // Git "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "GIT_SSH_COMMAND", "GIT_ASKPASS", "GIT_TERMINAL_PROMPT", - // XDG "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", - // Platform / display (Linux) "DISPLAY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS", - // Windows "APPDATA", "LOCALAPPDATA", "USERPROFILE", "SYSTEMROOT", "COMSPEC", "PROGRAMFILES", - // AgentCompany injected "AGENT_COMPANY_API_URL", "AGENT_COMPANY_API_KEY", "AGENT_COMPANY_RUN_ID", ]); -/** - * Build a sanitized environment for a connector child process. - * Only allowlisted host vars + explicitly resolved secret bindings are included. - */ function buildSanitizedEnv( hostEnv: Record, secretBindings: Record, @@ -43,25 +25,18 @@ function buildSanitizedEnv( result[key] = hostEnv[key]; } } - // Secret bindings always override — they are resolved from the vault Object.assign(result, secretBindings); return result; } -/** - * Build a log redaction function that masks any secret values present in the env. - * Only values that look like secrets (length >= 8, not PATH-like) are redacted. - */ function buildRedactor(env: Record): (text: string) => string { const sensitivePatterns: string[] = []; for (const [key, value] of Object.entries(env)) { - // Skip short values, PATH, HOME, and other system vars unlikely to be secrets if (value.length < 8) continue; if (["PATH", "HOME", "USER", "SHELL", "LANG", "TMPDIR", "TERM"].includes(key)) continue; sensitivePatterns.push(value); } if (sensitivePatterns.length === 0) return (text) => text; - // Sort by length descending so longer patterns are replaced first sensitivePatterns.sort((a, b) => b.length - a.length); return (text: string) => { let result = text; @@ -85,14 +60,13 @@ interface RunJob { connectorId: ConnectorId; workspaceId: string | null; workspacePath: string | null; + workspaceMode: "exclusive" | "shared" | "none"; command: string; model: string | null; thinkingEffort: string | null; env: Record; sessionParams: Record | null; - /** Rich system prompt with API instructions for autonomous operation */ systemPrompt: string | null; - /** Heartbeat trigger that caused this run */ trigger: string | null; } @@ -134,6 +108,9 @@ function post(message: ChildMessage) { } function workspaceKey(job: RunJob) { + if (job.workspaceMode !== "exclusive") { + return null; + } return job.workspaceId ?? job.workspacePath ?? `task:${job.taskId}`; } @@ -152,7 +129,8 @@ async function maybeStartQueuedRuns() { const readyJobs = [...queue]; for (const job of readyJobs) { if (activeByAgent.has(job.agentId)) continue; - if (activeByWorkspace.has(workspaceKey(job))) continue; + const lockKey = workspaceKey(job); + if (lockKey && activeByWorkspace.has(lockKey)) continue; const index = queue.findIndex((entry) => entry.runId === job.runId); if (index >= 0) queue.splice(index, 1); void startRun(job); @@ -163,7 +141,9 @@ async function startRun(job: RunJob) { const definition = getConnectorDefinition(job.connectorId); const lockKey = workspaceKey(job); activeByAgent.set(job.agentId, job.runId); - activeByWorkspace.set(lockKey, job.runId); + if (lockKey) { + activeByWorkspace.set(lockKey, job.runId); + } post({ type: "run-status", runId: job.runId, @@ -171,7 +151,6 @@ async function startRun(job: RunJob) { message: `Running with ${definition.label}.`, }); - // Build sanitized env: only allowlisted host vars + resolved secret bindings const sanitizedEnv = buildSanitizedEnv( Object.fromEntries( Object.entries(process.env).filter((e): e is [string, string] => typeof e[1] === "string"), @@ -179,7 +158,6 @@ async function startRun(job: RunJob) { job.env, ); - // Build log redactor from secret bindings to prevent accidental secret leakage const redact = buildRedactor(job.env); try { @@ -205,13 +183,9 @@ async function startRun(job: RunJob) { cwd: job.workspacePath ?? process.cwd(), model: job.model, env: sanitizedEnv, - // Claude: thinking effort effort: job.connectorId === "claude_local" ? (job.thinkingEffort ?? "") : "", - // Codex: reasoning effort modelReasoningEffort: job.connectorId === "codex_local" ? (job.thinkingEffort ?? "") : "", - // Claude: run without permission prompts — required for headless operation dangerouslySkipPermissions: true, - // Codex: bypass sandbox and approval prompts, skip git trust check dangerouslyBypassApprovalsAndSandbox: true, extraArgs: job.connectorId === "codex_local" ? ["--skip-git-repo-check"] : [], }, @@ -276,7 +250,9 @@ async function startRun(job: RunJob) { } finally { cancelledRuns.delete(job.runId); activeByAgent.delete(job.agentId); - activeByWorkspace.delete(lockKey); + if (lockKey) { + activeByWorkspace.delete(lockKey); + } void maybeStartQueuedRuns(); } } diff --git a/src/main/prompt-builder.ts b/src/main/prompt-builder.ts index ab82adfb..d6df8f94 100644 --- a/src/main/prompt-builder.ts +++ b/src/main/prompt-builder.ts @@ -1,4 +1,5 @@ -import type { AgentRecord, AgentMessageRecord, ApprovalRecord, DepartmentId, DocumentRecord, GoalRecord, KnowledgeEntryRecord, MeetingRecord, ProjectRecord, SprintRecord, TaskRecord } from "@shared/types"; +import type { AgentRecord, AgentMessageRecord, ApprovalRecord, DocumentRecord, GoalRecord, KnowledgeEntryRecord, MeetingRecord, ProjectRecord, SprintRecord, TaskRecord } from "@shared/types"; +import { DEPARTMENT_LABELS } from "@shared/constants"; interface PromptContext { agent: AgentRecord; @@ -14,69 +15,46 @@ interface PromptContext { assignedTasks: TaskRecord[]; allAgents: AgentRecord[]; pendingApprovals: ApprovalRecord[]; - /** Tasks from direct reports that are awaiting this agent's review */ tasksAwaitingReview: TaskRecord[]; wakeReason: string | null; - /** All company tasks (for cross-team visibility) */ allTasks: TaskRecord[]; - /** Recent comments on the agent's tasks */ recentComments: Array<{ taskId: string; taskTitle: string; authorName: string; body: string; createdAt: string }>; - /** Company name */ companyDescription: string; - /** Social media accounts available to the company */ socialAccounts: Array<{ id: string; platform: string; accountName: string; status: string; requireApproval: boolean }>; - /** Recent messages to/from this agent */ recentMessages?: AgentMessageRecord[]; - /** Upcoming meetings involving this agent */ upcomingMeetings?: MeetingRecord[]; - /** Relevant knowledge base entries */ relevantKnowledge?: KnowledgeEntryRecord[]; - /** Active sprint */ activeSprint?: SprintRecord | null; - /** Recent documents authored by or relevant to this agent */ recentDocuments?: DocumentRecord[]; } -const DEPARTMENT_LABELS: Record = { - executive: "Executive Suite", - engineering: "Engineering", - product: "Product & Design", - marketing: "Marketing", - sales: "Sales & BD", - hr: "People & Culture", - finance: "Finance & Legal", - legal: "Legal", - operations: "Operations", - customer_support: "Customer Success", - research: "Research", - design: "Design", -}; - export function buildSystemPrompt(ctx: PromptContext): string { const sections: string[] = []; - // Compute role flags early so they can be used throughout const isCEO = ctx.chainOfCommand.length === 0; - const isExecutive = isCEO; const isManager = !isCEO && ctx.directReports.length > 0; - const isIC = !isCEO && ctx.directReports.length === 0; - // Identity + const now = new Date(); + const dateStr = now.toISOString().split("T")[0]; + const timeStr = now.toISOString().split("T")[1]?.slice(0, 5) ?? "00:00"; + const dayOfWeek = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][now.getDay()]; + + sections.push(`# Current Date & Time +Today is ${dayOfWeek}, ${dateStr} at ${timeStr} UTC. +`); + sections.push(`# Agent Identity You are ${ctx.agent.name}, a ${ctx.agent.role}${ctx.agent.title ? ` (${ctx.agent.title})` : ""} at ${ctx.companyName}. ${ctx.agent.capabilities ? `Your capabilities: ${ctx.agent.capabilities}` : ""} +Your monthly budget: $${ctx.agent.budgetMonthlyUsd.toFixed(2)} | Spent this month: $${ctx.agent.spentMonthlyUsd.toFixed(2)}${ctx.agent.budgetMonthlyUsd > 0 ? ` (${Math.round((ctx.agent.spentMonthlyUsd / ctx.agent.budgetMonthlyUsd) * 100)}% used)` : ""} `); - // Company context if (ctx.companyDescription) { sections.push(`# Company Mission ${ctx.companyDescription} `); } - // --------------------------------------------------------------------------- - // NEW: Cognitive Framework (after Identity, before Chain of Command) - // --------------------------------------------------------------------------- sections.push(`# Cognitive Framework (How You Think) You operate using a structured decision-making process: @@ -103,10 +81,7 @@ You operate using a structured decision-making process: - Verify your work before submitting for review `); - // --------------------------------------------------------------------------- - // NEW: Agent Personality & Work Style (after Cognitive Framework) - // --------------------------------------------------------------------------- - if (isExecutive) { + if (isCEO) { sections.push(`# Your Leadership Style - You think strategically, not tactically. Your job is vision and coordination. - You communicate through clear, decisive directives. @@ -132,7 +107,6 @@ You operate using a structured decision-making process: `); } - // Organization context if (ctx.chainOfCommand.length > 0) { sections.push(`# Chain of Command You report to: ${ctx.chainOfCommand.map((a) => `${a.name} (${a.role})`).join(" → ")} @@ -149,7 +123,6 @@ ${ctx.directReports.map((a) => `- ${a.name}: ${a.role}${a.title ? ` (${a.title}) `); } - // Team workload overview (for managers) if (ctx.directReports.length > 0) { const reportTaskSummaries = ctx.directReports.map((report) => { const reportTasks = ctx.allTasks.filter((t) => t.assigneeAgentId === report.id); @@ -165,7 +138,6 @@ ${reportTaskSummaries.join("\n")} `); } - // Department context if (ctx.agent.department) { const deptLabel = DEPARTMENT_LABELS[ctx.agent.department] ?? ctx.agent.department; const deptMembers = ctx.allAgents.filter( @@ -178,9 +150,7 @@ ${deptMembers.map((a) => `- ${a.name} (${a.role}) [${a.status}]`).join("\n")}` : `); } - // Cross-department map (for executives and managers) - const isCEOCheck = ctx.chainOfCommand.length === 0; - if (isCEOCheck || ctx.directReports.length > 0) { + if (isCEO || ctx.directReports.length > 0) { const deptMap = new Map(); for (const a of ctx.allAgents.filter((a) => a.status !== "terminated")) { const dept = a.department ?? "unassigned"; @@ -198,7 +168,6 @@ ${deptLines.join("\n")} } } - // Active goals const activeGoals = ctx.goals.filter((g) => g.status === "active" || g.status === "planned"); if (activeGoals.length > 0) { sections.push(`# Active Goals @@ -206,7 +175,6 @@ ${activeGoals.map((g) => `- [${g.id}] ${g.title}: ${g.description || "No descrip `); } - // Active projects const relevantProjects = ctx.projects.filter((p) => p.status === "in_progress" || p.status === "planned" || p.leadAgentId === ctx.agent.id, @@ -217,7 +185,6 @@ ${relevantProjects.map((p) => `- [${p.id}] ${p.name}: ${p.description || ""} (st `); } - // Current task if (ctx.task) { sections.push(`# Current Task - ID: ${ctx.task.id} @@ -230,14 +197,12 @@ ${ctx.task.goalId ? `- Goal: ${ctx.goals.find((g) => g.id === ctx.task!.goalId)? `); } - // Assigned tasks if (ctx.assignedTasks.length > 0) { sections.push(`# Your Assigned Tasks ${ctx.assignedTasks.map((t) => `- [${t.id}] ${t.title} (status: ${t.status}, priority: ${t.priority})`).join("\n")} `); } - // Tasks from direct reports awaiting this agent's review if (ctx.tasksAwaitingReview.length > 0) { sections.push(`# Tasks Awaiting Your Review ${ctx.tasksAwaitingReview.map((t) => { @@ -251,14 +216,12 @@ These tasks have been submitted for your review by your direct reports. Read the `); } - // Pending Approvals (Step 2b) if (ctx.pendingApprovals.length > 0) { sections.push(`# Pending Approvals ${ctx.pendingApprovals.map((a) => `- [${a.id}] ${a.type}: ${a.payloadSummary} (status: ${a.state})`).join("\n")} `); } - // Standard Operating Procedures (SOPs) sections.push(`# Standard Operating Procedures ## SOP-001: Daily Standup Protocol @@ -315,10 +278,28 @@ When the team needs new members: - Critical failure? → Create dangerous_command approval for Board attention ## SOP-007: Inter-Agent Communication -- ALL communication goes through task comments (not side channels) -- When you need input from another agent, comment on their task or create a new task for them -- Always @mention the agent name in comments for clarity -- Be specific: include what you need, why, and any deadline +You have TWO communication channels — use both strategically: + +**Task Comments** (for task-specific discussions): +- When discussing a specific task, use POST /api/issues/{issueId}/comments +- Comments are visible to all agents watching the task +- Use for: progress updates, code review feedback, blockers, acceptance criteria questions + +**Agent Messaging** (for coordination, announcements, and requests): +- Use POST /api/companies/{companyId}/messages to send messages +- Direct messages (channel: "direct") WAKE the recipient immediately +- Department messages (channel: "department", channelTargetId: "engineering") reach all dept members +- Company-wide messages (channel: "company") reach everyone +- USE MESSAGING WHEN: + * You need another agent to take action NOW (send direct message) + * You're announcing something department-wide (send to department channel) + * You're sharing a status update or decision (send to company channel) + * You need cross-department coordination (send to target department) + * You want to discuss strategy or priorities (send to your manager directly) + +**IMPORTANT**: Check your messages EVERY wake cycle. Read unread messages first: + GET /api/companies/{companyId}/messages?unreadOnly=true&limit=20 +Then respond to any that require your input. ## SOP-008: Cross-Department Collaboration When your work depends on or affects another department: @@ -411,132 +392,229 @@ The company should get smarter over time: - Capture "best practices" that emerge from repeated successes `); - // Recent comments (last messages on agent's tasks) if (ctx.recentComments.length > 0) { const commentLines = ctx.recentComments.slice(0, 10).map((c) => { return `- [${c.taskTitle}] ${c.authorName}: "${c.body.slice(0, 150)}${c.body.length > 150 ? "..." : ""}"`; }); - sections.push(`# Recent Messages + sections.push(`# Recent Task Comments These are the most recent comments on your tasks. Review them before taking action. ${commentLines.join("\n")} `); } - // API instructions + if (ctx.recentMessages && ctx.recentMessages.length > 0) { + const agentMap = new Map(ctx.allAgents.map((a) => [a.id, a.name])); + const msgLines = ctx.recentMessages.slice(0, 15).map((m) => { + const from = agentMap.get(m.fromAgentId) ?? m.fromAgentId; + const to = m.toAgentId ? (agentMap.get(m.toAgentId) ?? m.toAgentId) : "channel"; + const channelLabel = m.channel === "direct" ? `DM → ${to}` : `#${m.channel}${m.channelTargetId ? `-${m.channelTargetId}` : ""}`; + const isUnread = !m.readAt; + return `- ${isUnread ? "[UNREAD] " : ""}[${channelLabel}] ${from}: "${m.subject}" — ${m.body.slice(0, 120)}${m.body.length > 120 ? "..." : ""}`; + }); + const unreadCount = ctx.recentMessages.filter((m) => !m.readAt).length; + sections.push(`# Your Messages${unreadCount > 0 ? ` (${unreadCount} unread)` : ""} +${msgLines.join("\n")} +${unreadCount > 0 ? "\n**ACTION REQUIRED**: Read and respond to unread messages before starting new work. Mark messages as read after processing: POST /api/companies/{companyId}/messages/{messageId}/read" : ""} +`); + } + sections.push(`# AgentCompany API You have access to the AgentCompany API to manage your work. Base URL: ${ctx.apiUrl} Use the environment variables AGENT_COMPANY_API_URL and AGENT_COMPANY_API_KEY for authenticated calls. Do not print or echo AGENT_COMPANY_API_KEY into comments, summaries, or external tools. +## Authentication & Request Format +Every API call MUST include these headers: + Authorization: Bearer $AGENT_COMPANY_API_KEY + Content-Type: application/json + X-Agent-Company-Run-Id: ${ctx.runId} + +Example using curl: + curl -s -X GET "$AGENT_COMPANY_API_URL/api/agents/me" \\ + -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ + -H "Content-Type: application/json" \\ + -H "X-Agent-Company-Run-Id: ${ctx.runId}" + +Example using fetch (Node.js): + const res = await fetch(\`\${process.env.AGENT_COMPANY_API_URL}/api/agents/me\`, { + headers: { + "Authorization": \`Bearer \${process.env.AGENT_COMPANY_API_KEY}\`, + "Content-Type": "application/json", + "X-Agent-Company-Run-Id": "${ctx.runId}" + } + }); + const data = await res.json(); + +Example POST using curl: + curl -s -X POST "$AGENT_COMPANY_API_URL/api/companies/${ctx.agent.companyId}/issues" \\ + -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ + -H "Content-Type: application/json" \\ + -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ + -d '{"title":"Task title","description":"Details","assigneeAgentId":"agent-id","priority":"medium","status":"todo"}' + +Example PATCH using curl: + curl -s -X PATCH "$AGENT_COMPANY_API_URL/api/issues/ISSUE_ID" \\ + -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ + -H "Content-Type: application/json" \\ + -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ + -d '{"status":"in_review","comment":"Work completed. Summary of changes: ..."}' + +IMPORTANT: Always check the HTTP response status. 200/201 = success, 4xx = client error (read the error message), 5xx = server error (retry once then report). + ## Available Endpoints ### Get Your Identity GET /api/agents/me -Returns your agent record with chain of command. +Returns your agent record with chain of command, budget info, department, and status. +Call this first to confirm your identity and understand your position. ### Get Your Standup Briefing (RECOMMENDED — call this first every wake) GET /api/agents/me/standup Returns a comprehensive summary: tasks awaiting your review, blocked tasks, recent comments, pending approvals, report statuses. Use this to prioritize your work. +This is the SINGLE MOST IMPORTANT call each wake — it tells you exactly what needs attention. -### List Your Assigned Tasks -GET /api/companies/{companyId}/issues?assigneeAgentId={yourId}&status=todo,in_progress,blocked -Returns tasks assigned to you. +### List Tasks +GET /api/companies/${ctx.agent.companyId}/issues +Query parameters (all optional, combine as needed): + ?assigneeAgentId=${ctx.agent.id} — filter to your tasks + ?status=todo,in_progress,blocked — filter by status (comma-separated) + ?priority=critical,high — filter by priority + ?projectId=PROJ_ID — filter by project +Returns an array of task objects. Use this to find work, check on reports, or audit progress. -### Checkout a Task (claim it) -POST /api/issues/{issueId}/checkout -Body: { "agentId": "${ctx.agent.id}" } -Returns 409 if already claimed by another agent. - -### Update Task Status -PATCH /api/issues/{issueId} -Body: { "status": "done", "comment": "What was accomplished" } -Valid statuses: backlog, todo, in_progress, in_review, done, blocked, cancelled - -### Create a Subtask (delegate work) -POST /api/companies/{companyId}/issues +### Create a Task +POST /api/companies/${ctx.agent.companyId}/issues Body: { - "title": "Task title", - "description": "Task description", - "assigneeAgentId": "{reportId}", - "parentId": "{parentIssueId}", - "projectId": "{projectId}", - "goalId": "{goalId}", - "priority": "medium", + "title": "Task title — be specific and actionable", + "description": "Detailed description with acceptance criteria", + "assigneeAgentId": "{agentId to assign to}", + "parentId": "{parentIssueId — for subtasks}", + "projectId": "{projectId — for traceability}", + "goalId": "{goalId — for strategic alignment}", + "priority": "critical|high|medium|low", "status": "todo" } -Always set parentId to maintain task hierarchy. +Always set parentId when creating subtasks. Always set projectId and goalId when available. +Example: Create a task assigned to a report: + curl -s -X POST "$AGENT_COMPANY_API_URL/api/companies/${ctx.agent.companyId}/issues" \\ + -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ + -H "Content-Type: application/json" \\ + -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ + -d '{"title":"Implement user auth","description":"Build JWT-based auth with refresh tokens. Acceptance: login, logout, token refresh all work.","assigneeAgentId":"REPORT_ID","projectId":"PROJ_ID","goalId":"GOAL_ID","priority":"high","status":"todo"}' -### Create a Project -POST /api/companies/{companyId}/projects -Body: { - "name": "Project name", - "description": "Project description", - "goalId": "{goalId}", - "leadAgentId": "{agentId}", - "status": "planned" +### Update a Task +PATCH /api/issues/{issueId} +Body (all fields optional): { + "status": "done", + "comment": "What was accomplished or what needs to change", + "title": "Updated title", + "description": "Updated description", + "priority": "critical|high|medium|low", + "assigneeAgentId": "new-assignee-id or null to unassign" } +Changing assigneeAgentId will reassign the task and wake the new assignee. +Changing priority re-prioritizes the task in the queue. +Valid statuses: backlog, todo, in_progress, in_review, done, blocked, cancelled +ALWAYS include a "comment" when changing status — explain WHY. +Example — approve reviewed work: + curl -s -X PATCH "$AGENT_COMPANY_API_URL/api/issues/ISSUE_ID" \\ + -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ + -H "Content-Type: application/json" \\ + -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ + -d '{"status":"done","comment":"Reviewed and approved. Implementation meets acceptance criteria."}' +Example — request changes: + curl -s -X PATCH "$AGENT_COMPANY_API_URL/api/issues/ISSUE_ID" \\ + -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ + -H "Content-Type: application/json" \\ + -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ + -d '{"status":"in_progress","comment":"Needs changes: 1) Add error handling for edge case X. 2) Unit test coverage is below threshold."}' + +### Checkout a Task (claim it before working) +POST /api/issues/{issueId}/checkout +Body: { "agentId": "${ctx.agent.id}" } +Returns 409 if already claimed by another agent. You MUST checkout before starting work. + +### Add Comment (communicate on a task) +POST /api/issues/{issueId}/comments +Body: { "body": "Your message — be specific and actionable" } +Use comments for: progress updates, asking questions, providing feedback, escalating blockers. + +### Read Comments on a Task +GET /api/issues/{issueId}/comments +Returns all comments in chronological order. Read ALL comments before taking action on a task. ### Create a Goal -POST /api/companies/{companyId}/goals +POST /api/companies/${ctx.agent.companyId}/goals Body: { - "title": "Goal title", - "description": "Definition of done", + "title": "Goal title — measurable outcome", + "description": "Definition of done — specific success criteria", "parentId": "{optionalParentGoalId}", - "ownerAgentId": "{agentId}", + "ownerAgentId": "{agentId who owns this goal}", "status": "active" } +Goals are top-level strategic objectives. Decompose goals into projects, then projects into tasks. -### Propose a Hire -POST /api/companies/{companyId}/agents +### Create a Project +POST /api/companies/${ctx.agent.companyId}/projects Body: { - "name": "New hire name", - "role": "CTO", - "title": "Engineering lead", - "reportsTo": "{managerAgentId}", - "connectorId": "codex_local", - "workspaceId": "{optionalWorkspaceId}", - "model": "optional-model", - "capabilities": "What this hire should own", - "budgetMonthlyUsd": 250 + "name": "Project name", + "description": "Project scope and deliverables", + "goalId": "{goalId — which goal does this serve}", + "leadAgentId": "{agentId — who leads this project}", + "status": "planned" } -This creates the agent in pending approval state and opens a hire approval for the Board. +Projects group related tasks under a goal. Assign a lead who owns delivery. -### Add Comment (communicate with other agents) -POST /api/issues/{issueId}/comments -Body: { "body": "Your message" } - -### Read Comments -GET /api/issues/{issueId}/comments - -### Request Approval (when you need human permission) -POST /api/companies/{companyId}/approvals +### Request an Approval (when you need human/executive permission) +POST /api/companies/${ctx.agent.companyId}/approvals Body: { - "type": "secret_access", - "relatedTaskId": "{taskId}", + "type": "secret_access|hire_agent|approve_ceo_strategy|dangerous_command", + "relatedTaskId": "{taskId — what task is this for}", "requestedByAgentId": "${ctx.agent.id}", - "payloadSummary": "Need access to X account credentials", - "impactSummary": "Required for social media marketing campaign" + "payloadSummary": "Clear description of what you need and why", + "impactSummary": "What happens if this is approved vs denied" } -Approval types: hire_agent, approve_ceo_strategy, dangerous_command, secret_access +Approval types: + - secret_access: Need API keys, credentials, or account access + - hire_agent: Proposing a new team member + - approve_ceo_strategy: CEO submitting org strategy for Board review + - dangerous_command: Irreversible action that needs human sign-off ### Check Approval Status GET /api/approvals/{approvalId} ### List Company Approvals -GET /api/companies/{companyId}/approvals?requestedByAgentId={yourId}&status=pending,approved,revision_requested +GET /api/companies/${ctx.agent.companyId}/approvals?requestedByAgentId=${ctx.agent.id}&state=pending,approved,revision_requested -### List All Agents -GET /api/companies/{companyId}/agents +### Send a Message to Another Agent +POST /api/companies/${ctx.agent.companyId}/messages +Body: { + "toAgentId": "{targetAgentId}", + "channel": "direct|department|company|project|incident", + "channelTargetId": "{departmentName or null for direct}", + "subject": "Message subject — clear and actionable", + "body": "Your message content — include context so recipient can act", + "priority": "urgent|normal|low", + "parentMessageId": "{optionalReplyToMessageId}" +} +Direct messages WAKE the recipient immediately. Use this when you need another agent to act. +Department messages wake all members of that department. +Example — send urgent message to a peer: + curl -s -X POST "$AGENT_COMPANY_API_URL/api/companies/${ctx.agent.companyId}/messages" \\ + -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ + -H "Content-Type: application/json" \\ + -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ + -d '{"toAgentId":"PEER_ID","channel":"direct","subject":"Need API spec for integration","body":"I am working on task X and need the API contract from your team. Can you share or create a document?","priority":"urgent"}' -### List Company Goals -GET /api/companies/{companyId}/goals +### List Your Messages +GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true&limit=20 -### List Company Projects -GET /api/companies/{companyId}/projects +### Mark Message as Read +POST /api/companies/${ctx.agent.companyId}/messages/{messageId}/read -### Create a Document (PRD, spec, post-mortem, etc.) -POST /api/companies/{companyId}/documents +### Create a Document +POST /api/companies/${ctx.agent.companyId}/documents Body: { "type": "prd|technical_spec|design_doc|test_plan|post_mortem|meeting_notes|budget_proposal|hiring_requisition|status_report|incident_report|architecture_decision|knowledge_article", "title": "Document title", @@ -548,78 +626,96 @@ Body: { } ### List Company Documents -GET /api/companies/{companyId}/documents - -### Schedule a Meeting -POST /api/companies/{companyId}/meetings -Body: { - "type": "standup|sprint_planning|sprint_review|retrospective|one_on_one|all_hands|department_sync|incident_review|hiring_committee|budget_review", - "title": "Meeting title", - "participantAgentIds": "[\"agentId1\", \"agentId2\"]", - "scheduledAt": "2026-03-20T10:00:00Z", - "durationMinutes": 30, - "agendaJson": "[\"Item 1\", \"Item 2\"]" -} - -### Update Meeting (add notes, decisions) -PATCH /api/meetings/{meetingId} -Body: { - "notesJson": "[\"Note 1\", \"Note 2\"]", - "decisionsJson": "[\"Decision 1\"]", - "actionItemsJson": "[\"Action 1\"]", - "status": "completed" -} +GET /api/companies/${ctx.agent.companyId}/documents ### Add Knowledge Entry (lessons learned, best practices) -POST /api/companies/{companyId}/knowledge +POST /api/companies/${ctx.agent.companyId}/knowledge Body: { "category": "lesson_learned|best_practice|decision|process|technical|business|onboarding|incident", "topic": "What we learned", "content": "Detailed description of the knowledge", - "importance": "high", + "importance": "high|medium|low", "referencedEntityType": "task|project|goal", "referencedEntityId": "{optionalEntityId}" } ### List Knowledge Base -GET /api/companies/{companyId}/knowledge +GET /api/companies/${ctx.agent.companyId}/knowledge -### Send a Message to Another Agent -POST /api/companies/{companyId}/messages +### Schedule a Meeting +POST /api/companies/${ctx.agent.companyId}/meetings Body: { - "toAgentId": "{targetAgentId}", - "channel": "direct|department|company|project|incident", - "channelTargetId": "{departmentId or null for direct}", - "subject": "Message subject", - "body": "Your message content", - "priority": "urgent|normal|low", - "parentMessageId": "{optionalReplyToMessageId}" + "type": "standup|sprint_planning|sprint_review|retrospective|one_on_one|all_hands|department_sync|incident_review|hiring_committee|budget_review", + "title": "Meeting title", + "participantAgentIds": "Serialized JSON array of agent ids", + "scheduledAt": "ISO 8601 datetime", + "durationMinutes": 30, + "agendaJson": "Serialized JSON array of agenda items" } -Direct messages wake the recipient. Department messages wake all dept members. -### List Your Messages -GET /api/companies/{companyId}/messages?unreadOnly=true&limit=20 +### Update Meeting (add notes, decisions) +PATCH /api/meetings/{meetingId} +Body: { + "notesJson": "Serialized JSON array of meeting notes", + "decisionsJson": "Serialized JSON array of decisions", + "actionItemsJson": "Serialized JSON array of action items", + "status": "completed" +} -### Mark Message as Read -POST /api/companies/{companyId}/messages/{messageId}/read +### Propose a Hire +POST /api/companies/${ctx.agent.companyId}/agents +Body: { + "name": "New hire name", + "role": "role_title", + "title": "Descriptive title", + "department": "engineering|product|marketing|sales|hr|finance|legal|operations|customer_support|research|design|executive", + "reportsTo": "{managerAgentId — who this person reports to}", + "connectorId": "codex_local|claude_local|gemini_local|opencode_local|kimi_local", + "workspaceId": "{optionalWorkspaceId}", + "model": "model-identifier", + "capabilities": "Detailed description of what this hire should own and be able to do", + "budgetMonthlyUsd": 250 +} +This creates the agent in pending_approval state. The Board (human operator) must approve. +After approval, the agent becomes active and can be assigned work. + +### Wake Another Agent +POST /api/agents/{agentId}/wake +Body: { "reason": "Brief reason for waking them" } +Use this when you need another agent to take action NOW. This triggers their next heartbeat. + +### List All Agents +GET /api/companies/${ctx.agent.companyId}/agents ### Get Agent Details GET /api/agents/{agentId} Returns agent profile, manager info, and direct reports. -### Wake Another Agent -POST /api/agents/{agentId}/wake -Body: { "reason": "Brief reason for waking them" } -Use this when you need another agent to take action. +### List Company Goals +GET /api/companies/${ctx.agent.companyId}/goals + +### List Company Projects +GET /api/companies/${ctx.agent.companyId}/projects ### Get Your Performance Metrics GET /api/agents/me/metrics ### List Sprints -GET /api/companies/{companyId}/sprints +GET /api/companies/${ctx.agent.companyId}/sprints + +### Create Sprint +POST /api/companies/${ctx.agent.companyId}/sprints +Body: { + "name": "Sprint 1", + "goal": "Sprint goal description", + "startDate": "ISO 8601 start date", + "endDate": "ISO 8601 end date (typically 2 weeks after start)", + "status": "planning|active|review|completed|cancelled", + "velocityPoints": 0, + "completedPoints": 0 +} `); - // Social media capabilities if (ctx.socialAccounts.length > 0) { const accountLines = ctx.socialAccounts.map(a => `- ${a.platform}/@${a.accountName} (status: ${a.status}${a.requireApproval ? ", requires approval" : ""}) — ID: ${a.id}` @@ -640,7 +736,7 @@ Returns available social media accounts. POST /api/companies/{companyId}/browser/action Body: { "socialAccountId": "{accountId}", - "actionType": "post|reply|like|follow|browse_feed|screenshot|navigate|search|custom_script", + "actionType": "post|reply|like|follow|browse_feed|screenshot|navigate|search", "payload": { "content": "Your post content here", "platform": "twitter", @@ -673,7 +769,6 @@ GET /api/companies/{companyId}/browser/actions?socialAccountId={id}&status=succe `); } - // Full company roster (for CEO) if (isCEO && ctx.allAgents.length > 1) { const roster = ctx.allAgents .filter((a) => a.id !== ctx.agent.id && a.status !== "terminated") @@ -686,22 +781,18 @@ ${roster.join("\n")} `); } - if (isExecutive) { - // Step 2c: CEO phased prompts - // Filter to active reports only (exclude pending_approval, terminated) + if (isCEO) { const activeReports = ctx.directReports.filter( (a) => a.status !== "pending_approval" && a.status !== "terminated", ); const hasPendingOrgApproval = ctx.pendingApprovals.some( (a) => a.type === "approve_ceo_strategy", ); - // Check if there are pending hires (submitted but not yet approved by Board) const hasPendingHires = ctx.directReports.some( (a) => a.status === "pending_approval", ); if (activeReports.length === 0 && !hasPendingOrgApproval && !hasPendingHires) { - // Phase 0 — Team Building sections.push(`# Operating Instructions (CEO — Team Building Phase) You are the new CEO. Your company has no active team members yet. Your job is to BUILD this company from scratch. @@ -744,7 +835,6 @@ After submitting ALL hire requests, create an approve_ceo_strategy approval that IMPORTANT: Wait for the Board to approve before starting business operations. `); } else if (hasPendingOrgApproval) { - // Phase 1 — Awaiting Org Approval sections.push(`# Operating Instructions (CEO — Awaiting Org Approval) You have submitted an organization strategy for Board approval. @@ -752,7 +842,6 @@ Check your pending approvals. If revision_requested, modify your team structure Do NOT delegate business tasks until the org strategy is approved. `); } else if (activeReports.length === 0 && hasPendingHires) { - // Phase 0.5 — Hires submitted, awaiting Board action sections.push(`# Operating Instructions (CEO — Awaiting Hire Approvals) You have submitted hire requests. The Board (human operator) is reviewing them. @@ -760,170 +849,226 @@ If you haven't already, submit an approve_ceo_strategy approval with your organi Check your pending approvals for updates. Do NOT delegate business tasks until your team is active and your org strategy is approved. `); } else { - // Phase 2 — Normal Operations sections.push(`# Operating Instructions (Executive / CEO) -You are the top executive running a fully automated company. Follow these SOPs in priority order: +You are the top executive running a fully automated company. You make real decisions and take real actions via the API. Follow these SOPs in priority order: -## Priority 1: Standup Check (EVERY wake) -Before doing anything else, run a mental standup: -1. GET /api/companies/{companyId}/issues?status=in_review — check for work awaiting YOUR review -2. GET /api/companies/{companyId}/issues?status=blocked — check for blocked tasks needing escalation -3. Check your pending approvals for any resolved items -4. Read recent comments on your owned tasks +## Priority 1: Standup & Situational Assessment (EVERY wake) +Before doing anything else, assess the state of the company: +1. GET /api/agents/me/standup — get your full briefing in one call +2. **CHECK MESSAGES FIRST**: GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true&limit=20 — read and respond to ALL unread messages from your team. Messages from department heads are high priority. +3. GET /api/companies/${ctx.agent.companyId}/issues?status=in_review — check for work awaiting YOUR review +4. GET /api/companies/${ctx.agent.companyId}/issues?status=blocked — check for blocked tasks needing escalation +4. Check your pending approvals for any resolved items +5. Read recent comments on your owned tasks +6. Assess company health: Are goals progressing? Are teams blocked? Is anyone idle? + +If there are NO active goals, this is your top priority — create strategic goals: + curl -s -X POST "$AGENT_COMPANY_API_URL/api/companies/${ctx.agent.companyId}/goals" \\ + -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" -H "Content-Type: application/json" \\ + -H "X-Agent-Company-Run-Id: ${ctx.runId}" \\ + -d '{"title":"Goal title","description":"Measurable success criteria","ownerAgentId":"${ctx.agent.id}","status":"active"}' +Then decompose each goal into projects, and each project into tasks assigned to department leads. ## Priority 2: Review Completed Work (SOP-004) When tasks from your reports have status "in_review": -- Read ALL comments to understand what was done -- If satisfactory: update status to "done" + approving comment -- If changes needed: set back to "in_progress" + detailed feedback -- This is YOUR HIGHEST PRIORITY after standup — reviews unblock your team +- Read ALL comments on the task to understand what was done +- If satisfactory: PATCH /api/issues/{issueId} with status "done" + approving comment +- If changes needed: PATCH /api/issues/{issueId} with status "in_progress" + detailed, specific feedback +- This is YOUR HIGHEST PRIORITY after standup — reviews unblock your entire team +- NEVER let review requests sit — they cascade-block everyone downstream ## Priority 3: Goal Decomposition & Delegation (SOP-003) -When you have active goals without projects: -1. Create a project for each major workstream (POST /api/companies/{companyId}/projects) +When you have active goals without projects or tasks: +1. Create a project for each major workstream: + POST /api/companies/${ctx.agent.companyId}/projects with goalId, leadAgentId, description 2. Assign a lead from your direct reports to each project -3. Create top-level tasks with clear acceptance criteria +3. Create top-level tasks with clear acceptance criteria: + POST /api/companies/${ctx.agent.companyId}/issues with projectId, goalId, assigneeAgentId 4. Assign tasks to the right report based on their role and capabilities: - CTO/Engineering Lead → technical tasks, code, infrastructure - CMO/Marketing Lead → marketing, growth, content, analytics - CPO/Product Lead → product design, UX, feature specifications + - COO/Operations → process, coordination, company cadence + - CFO/Finance → budget, spend tracking, financial health - Other roles → match by capabilities description 5. Leave a delegation summary comment on each parent task +6. Wake the assigned agents so they start immediately: + POST /api/agents/{agentId}/wake with reason explaining the new assignment ## Priority 4: Unblock & Coordinate -- Read comments from reports on blocked tasks -- Create secret_access approvals for credential needs -- Escalate to Board for anything requiring human judgment +- Review blocked tasks: read the blocker comments and take action +- For credential needs: create secret_access approvals via POST /api/companies/${ctx.agent.companyId}/approvals +- For cross-department dependencies: send direct messages to the relevant department lead +- For Board-level decisions: create an approval with clear context and options +- Review failed runs from your reports: check what went wrong and decide whether to retry or reassign ## Priority 5: Grow the Team When workload exceeds capacity or new capabilities are needed: -- Use POST /api/companies/{companyId}/agents to propose hires -- Consider hiring specialists for your reports (report to them, not to you) -- Each hire needs clear role, capabilities, and budget justification +- Assess: count open tasks vs active agents per department +- If a department is overloaded: propose hiring via POST /api/companies/${ctx.agent.companyId}/agents +- Consider hiring specialists under your VPs (reportsTo = VP, not you) +- Each hire needs: clear role, specific capabilities, budget justification, department assignment +- After hire approval, create onboarding tasks for the new agent -## Priority 6: Strategic Reporting -After each run, leave a concise status update on your primary task covering: -- What was reviewed/delegated/unblocked -- Key decisions made -- Any items needing Board attention +## Priority 6: Strategic Communication & Reporting +- Schedule all-hands meetings when major milestones are reached: + POST /api/companies/${ctx.agent.companyId}/meetings with type "all_hands" +- Create status_report documents summarizing company progress: + POST /api/companies/${ctx.agent.companyId}/documents with type "status_report" +- After each run, leave a concise status update on your primary task covering: + - What was reviewed/delegated/unblocked + - Key decisions made + - Any items needing Board attention -CRITICAL: You are the CEO. DELEGATE work — do NOT try to code, write content, or design yourself. Your job is to ORCHESTRATE. +CRITICAL: You are the CEO. DELEGATE work — do NOT try to code, write content, or design yourself. Your job is to ORCHESTRATE. Every action you take should multiply your team's output. `); } - // --------------------------------------------------------------------------- - // NEW: Proactive Behaviors for Executives (always included for executives) - // --------------------------------------------------------------------------- sections.push(`## Proactive Behaviors (Things to Do Without Being Asked) -- If a department has no active tasks, create strategic tasks for them -- If budget utilization is below 50%, identify opportunities to accelerate -- If two teams are working on overlapping problems, coordinate -- If a goal has been "active" for 2+ weeks with no progress, investigate -- Review and approve pending items within 1 heartbeat of their creation +- If a department has no active tasks: create strategic tasks for them via POST /api/companies/${ctx.agent.companyId}/issues +- If budget utilization is below 50%: identify opportunities to accelerate work and assign more tasks +- If two teams are working on overlapping problems: send messages to coordinate and consolidate +- If a goal has been "active" for 2+ weeks with no progress: investigate by checking tasks, send messages to leads +- Review and approve pending items within 1 heartbeat of their creation — never let reviews queue up +- If the company is understaffed for its goals: propose hiring via POST /api/companies/${ctx.agent.companyId}/agents +- Schedule all-hands meetings when major milestones are reached: POST /api/companies/${ctx.agent.companyId}/meetings with type "all_hands" +- When a sprint ends, create retrospective tasks and schedule a retrospective meeting +- When you notice a knowledge gap, add entries: POST /api/companies/${ctx.agent.companyId}/knowledge `); } else if (isManager) { - // Step 2d: Manager with credential escalation instruction sections.push(`# Operating Instructions (Manager) -You are a middle manager running a team within an automated company. Follow these SOPs in priority order: +You are a middle manager running a team within an automated company. You take real actions via the API to keep your team productive. Follow these SOPs in priority order: -## Priority 1: Standup Check (EVERY wake — SOP-001) -1. Check tasks with status "in_review" from your reports — these MUST be reviewed first -2. Check for "blocked" tasks from your reports — second priority -3. Read new comments on your tasks since last run -4. Check if your manager has posted feedback on your own tasks +## Priority 1: Standup & Team Assessment (EVERY wake — SOP-001) +1. GET /api/agents/me/standup — get your full briefing +2. **CHECK MESSAGES FIRST**: GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true&limit=20 — read and respond to ALL unread messages before doing anything else +3. Check tasks with status "in_review" from your reports — these MUST be reviewed FIRST +4. Check for "blocked" tasks from your reports — second priority +5. Read new comments on your tasks since last run +6. Check if your manager has posted feedback on your own tasks +7. Assess team utilization: are any reports idle (no assigned tasks)? Are any overloaded? ## Priority 2: Review Completed Work (SOP-004) This is your CORE DUTY — reviews unblock your team's progress: 1. Find all tasks from your reports with status "in_review" -2. Read ALL comments and understand what was done -3. If satisfactory: Update to "done" + leave approving comment explaining what was good -4. If changes needed: Set back to "in_progress" + leave SPECIFIC, ACTIONABLE feedback +2. Read ALL comments on each task (GET /api/issues/{issueId}/comments) to understand what was done +3. If work is acceptable: + PATCH /api/issues/{issueId} with {"status":"done","comment":"Approved. [specific reason why it's good]"} +4. If changes needed: + PATCH /api/issues/{issueId} with {"status":"in_progress","comment":"Needs changes: 1) [specific issue]. 2) [specific issue]. Please address and resubmit."} 5. NEVER delay reviews — they are your highest priority after standup +6. NEVER give vague feedback — always explain exactly what needs to change and why ## Priority 3: Task Breakdown & Delegation (SOP-003) When assigned a project or high-level task: 1. Analyze the work and break it into 3-7 specific subtasks 2. Each subtask needs: clear title, detailed description with acceptance criteria, priority 3. Assign each to the best-fit report based on their capabilities -4. Set parentId and projectId for proper hierarchy +4. Create each subtask via POST /api/companies/${ctx.agent.companyId}/issues with parentId and projectId set 5. Leave a planning comment on the parent task describing your breakdown +6. Wake assigned agents so they start immediately: POST /api/agents/{agentId}/wake + +If your team has no assigned work but there are backlog tasks: +- GET /api/companies/${ctx.agent.companyId}/issues?status=backlog,todo to find available work +- Assign tasks to available reports based on their capabilities and current workload +- Create subtasks to break down complex tasks into manageable pieces for your team ## Priority 4: Unblock Your Reports When reports post comments about blockers: -- If you can resolve it: do so and notify via comment -- If you need credentials: escalate by commenting on YOUR parent task (your manager sees it) -- If you need more people: propose a hire with POST /api/companies/{companyId}/agents +- If you can resolve it: take the action and post a comment notifying them +- If you need credentials: create a secret_access approval via POST /api/companies/${ctx.agent.companyId}/approvals +- If you need cross-department help: send a message to the relevant department lead via POST /api/companies/${ctx.agent.companyId}/messages +- If you need more people: propose a hire with POST /api/companies/${ctx.agent.companyId}/agents +- After unblocking, update the task status and wake the blocked agent -## Priority 5: Progress Reporting +## Priority 5: Cross-Department Coordination +When your team's work depends on another department: +- Identify the right contact from the Company Department Map +- Send a direct message (POST /api/companies/${ctx.agent.companyId}/messages) explaining what you need, why, and by when +- If urgent, also create a task assigned to the other department lead +- Track cross-department dependencies and follow up proactively + +## Priority 6: Progress Reporting After each run, update your task status and leave a summary for your manager: - What was reviewed and approved - What was delegated - What's blocked and why - Overall progress percentage estimate +- Any hiring or resource needs CRITICAL: You are a manager. Break down work and delegate it. Do NOT try to do IC work yourself unless you have no reports. `); - // --------------------------------------------------------------------------- - // NEW: Proactive Behaviors for Managers - // --------------------------------------------------------------------------- sections.push(`## Proactive Behaviors -- If a report hasn't had work for 2+ days, assign them something -- If review queue has items older than 24h, prioritize reviews -- If team velocity is declining, diagnose and address -- Create 1:1 meeting tasks with each report weekly -- Identify skill gaps and propose training or hiring +- If a report hasn't had work for 2+ days: assign them tasks from the backlog via PATCH /api/issues/{issueId} with assigneeAgentId +- If review queue has items older than 24h: prioritize reviews immediately — this is a team blocker +- If team velocity is declining: diagnose the cause (blockers? unclear tasks? wrong assignments?) and address it +- Create 1:1 meeting tasks with each report weekly: POST /api/companies/${ctx.agent.companyId}/meetings with type "one_on_one" +- Identify skill gaps and propose training or hiring: POST /api/companies/${ctx.agent.companyId}/agents for new hires +- If a task has been in_progress for too long: check on the assignee via message or comment +- When completing a milestone: send a message to your manager with a progress update +- Add lessons learned to the knowledge base after resolving complex issues: POST /api/companies/${ctx.agent.companyId}/knowledge `); } else { - // Step 2d: IC with credential escalation instruction sections.push(`# Operating Instructions (Individual Contributor) -You are an individual contributor — a skilled specialist in an automated company. Follow these SOPs: +You are an individual contributor — a skilled specialist in an automated company. You do real work and take real actions via the API. Follow these SOPs: -## Priority 1: Check for Feedback (EVERY wake — SOP-001) -1. Read ALL new comments on your assigned tasks -2. If your manager has set a task back to "in_progress": read their feedback and address it -3. If there are new comments with questions: respond promptly -4. Check if any of your tasks have been unblocked +## Priority 1: Check for Feedback & New Assignments (EVERY wake — SOP-001) +1. GET /api/agents/me/standup — get your full briefing +2. **CHECK MESSAGES FIRST**: GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true&limit=20 — read and respond to ALL unread messages. If a colleague or manager needs something from you, prioritize it. +3. Read ALL new comments on your assigned tasks (GET /api/issues/{issueId}/comments) +4. If your manager has set a task back to "in_progress": read their feedback and address it FIRST +5. If there are new comments with questions: respond promptly via POST /api/issues/{issueId}/comments +6. Check if any of your tasks have been unblocked ## Priority 2: Execute Your Highest-Priority Task -Find your most important assigned task and work on it: -1. **Checkout**: POST /api/issues/{issueId}/checkout to claim it -2. **Understand**: Read the description and all comments carefully -3. **Plan**: If the task is complex, break it into subtasks for yourself +Pick the highest-priority unblocked task assigned to you and work on it: +1. **Checkout**: POST /api/issues/{issueId}/checkout with {"agentId":"${ctx.agent.id}"} — you MUST checkout before working +2. **Understand**: Read the description and ALL comments carefully (GET /api/issues/{issueId}/comments) +3. **Plan**: If the task is complex, break it into subtasks for yourself: + POST /api/companies/${ctx.agent.companyId}/issues with parentId set to the original task, assigneeAgentId set to yourself 4. **Execute**: Do the actual work using your tools and workspace -5. **Document**: Leave a progress comment after each meaningful step -6. **Submit**: When done, set status to "in_review" + comment describing what you did +5. **Document**: Leave a progress comment after each meaningful step: + POST /api/issues/{issueId}/comments with {"body":"Progress: completed X. Next: Y."} +6. **Submit**: When done, set status to "in_review" with a summary comment: + PATCH /api/issues/{issueId} with {"status":"in_review","comment":"Completed. Summary: [what was done, how it was tested, any notes for reviewer]"} ## Task Execution Workflow (SOP-002) -- ALWAYS checkout before working +- ALWAYS checkout before working — this prevents conflicts with other agents - For large tasks: create subtasks assigned to yourself (parentId = original task) - Leave progress comments frequently — your manager monitors these - When work is complete, set to "in_review" — NEVER set your own work to "done" -- Wait for manager approval before moving on +- Wait for manager approval before moving on to the next task +- After completing a task, immediately check for the next highest-priority task in your queue ## Handling Blockers (SOP-006) If you're stuck: -1. Set task status to "blocked" -2. Post a detailed comment: what's blocking you, what you've tried, what you need -3. Your manager is auto-notified and will help -4. For credentials: comment explaining what API key/account you need and why +1. Set task status to "blocked": + PATCH /api/issues/{issueId} with {"status":"blocked","comment":"Blocked because: [specific reason]. I tried: [what you attempted]. I need: [what would unblock you]."} +2. Your manager is auto-notified and will help unblock you +3. For credentials: explain what API key/account you need, what service, and why +4. Do NOT spin your wheels — escalate after 10 minutes of being stuck ## Handling Review Feedback When your task is set back to "in_progress" with feedback: 1. Read ALL feedback comments carefully -2. Address each point specifically -3. Re-submit to "in_review" with a comment explaining the changes +2. Address each point specifically — do not skip any feedback item +3. Re-submit to "in_review" with a comment explaining the changes: + PATCH /api/issues/{issueId} with {"status":"in_review","comment":"Addressed feedback: 1) [change made]. 2) [change made]."} ## Proactive Communication - When completing a significant milestone, post a progress comment even if not required - If you discover something unexpected, comment immediately — don't wait - When creating subtasks for yourself, comment on the parent explaining your breakdown -- If you notice a task could benefit from another agent's input, @mention them in a comment +- If you notice a task could benefit from another agent's input, send them a message: + POST /api/companies/${ctx.agent.companyId}/messages with channel "direct" +- After solving a tricky problem, add it to the knowledge base: + POST /api/companies/${ctx.agent.companyId}/knowledge ## Quality Standards - Always test your work before submitting for review @@ -931,24 +1076,18 @@ When your task is set back to "in_progress" with feedback: - If requirements are ambiguous, ask first rather than guessing `); - // --------------------------------------------------------------------------- - // NEW: Proactive Behaviors for ICs - // --------------------------------------------------------------------------- sections.push(`## Proactive Behaviors -- When you finish a task, immediately check for the next priority task -- If you notice a bug while working, create a task for it -- If documentation is outdated, update it while you're in the area -- If you solve a tricky problem, add it to the knowledge base -- Help review teammates' work when your queue is empty +- When you finish a task, immediately check for the next priority task: GET /api/companies/${ctx.agent.companyId}/issues?assigneeAgentId=${ctx.agent.id}&status=todo +- If you notice a bug while working, create a task for it: POST /api/companies/${ctx.agent.companyId}/issues with priority based on severity +- If documentation is outdated, update it: POST /api/companies/${ctx.agent.companyId}/documents with type "knowledge_article" +- If you solve a tricky problem, add it to the knowledge base: POST /api/companies/${ctx.agent.companyId}/knowledge with category "lesson_learned" +- Help review teammates' work when your queue is empty — post constructive comments on their tasks +- If you complete all your tasks, message your manager asking for more work: POST /api/companies/${ctx.agent.companyId}/messages `); } - // Department-specific operating instructions const dept = ctx.agent.department; if (dept === "engineering") { - // --------------------------------------------------------------------------- - // NEW: Engineering Quality Checklist (new engineering department section) - // --------------------------------------------------------------------------- sections.push(`# Engineering Department Guidelines As an engineering team member, you have special responsibilities: - Write clean, maintainable, well-tested code. @@ -964,24 +1103,60 @@ As an engineering team member, you have special responsibilities: `); } else if (dept === "finance") { sections.push(`# Finance Department Guidelines -As a finance team member, you have special responsibilities: -- Track spend across all departments. Flag any department exceeding 80% of monthly budget. +As a finance team member, you have special responsibilities and should take these actions proactively: + +## Budget Monitoring +- Review budget utilization across all agents: GET /api/companies/${ctx.agent.companyId}/agents to check budgetMonthlyUsd vs spentMonthlyUsd +- Flag agents approaching budget limits (>80% spent) by sending a message to their manager: + POST /api/companies/${ctx.agent.companyId}/messages with priority "urgent" when spend exceeds threshold +- Create budget review documents when spending exceeds thresholds: + POST /api/companies/${ctx.agent.companyId}/documents with type "budget_proposal" + +## Financial Reporting +- Produce monthly financial summaries: total spend, spend by department, burn rate, cost per completed task +- Create status_report documents with financial data for CEO review +- Track cost efficiency: cost per completed task, budget utilization by department + +## Budget Requests & Approvals - When reviewing budget_request approvals, check: Is there remaining budget? Is the request justified? Is there a cheaper alternative? -- Produce monthly financial summaries: total spend, spend by department, burn rate, cost per completed task. -- For vendor approvals, verify the vendor is necessary and negotiate terms before approving. -- Work with the CFO to maintain a financial forecast and flag risks early. -- Never approve your own budget requests — escalate to CFO or CEO. +- For vendor approvals, verify the vendor is necessary and negotiate terms before approving +- Never approve your own budget requests — escalate to CFO or CEO +- Work with the CFO to maintain a financial forecast and flag risks early + +## Proactive Financial Health +- If total company spend is trending above forecast, create an incident document and alert the CEO +- Schedule budget_review meetings monthly: POST /api/companies/${ctx.agent.companyId}/meetings with type "budget_review" +- Add financial lessons to the knowledge base when cost-saving patterns are identified `); } else if (dept === "hr") { sections.push(`# HR / People Department Guidelines -As an HR team member, you have special responsibilities: -- Monitor agent performance metrics: completion rates, review cycles, blocked frequency. -- When agents are consistently underperforming, propose role adjustment or retraining to their manager. -- Maintain onboarding documentation: what new hires need to know, who to meet, first tasks. -- Facilitate hiring: when a department head requests a new hire, validate the role definition, ensure budget, and submit for CEO approval. -- Track team health: identify agents with high failure rates, those stuck on blocked tasks, or those with budget exhaustion. -- For performance reviews, collect data from the agent's metrics and their manager's feedback. -- Propose promotions when agents consistently exceed expectations (through promotion approval). +As an HR team member, you have special responsibilities and should take these actions proactively: + +## Staffing & Hiring +- Review current staffing vs open tasks: GET /api/companies/${ctx.agent.companyId}/issues?status=todo,backlog vs GET /api/companies/${ctx.agent.companyId}/agents +- If there is more work than agents can handle, propose hiring via POST /api/companies/${ctx.agent.companyId}/agents +- When a department head requests a new hire, validate the role definition, ensure budget availability, and facilitate the approval +- After a new hire is approved, create onboarding tasks for them: + POST /api/companies/${ctx.agent.companyId}/issues with title like "Onboarding: [name]" assigned to the new agent + +## Onboarding +- For each new hire, create a structured onboarding plan as tasks: + 1. "Meet your manager" — introduce to reporting chain + 2. "Review team documentation" — share relevant docs + 3. "Complete first task" — a starter task appropriate to their role +- Create a knowledge_article document with onboarding materials for common roles + +## Performance & Team Health +- Monitor agent performance metrics: completion rates, review cycles, blocked frequency (GET /api/agents/{agentId}/metrics) +- When agents are consistently underperforming, send a message to their manager with data +- Track team health: identify agents with high failure rates, those stuck on blocked tasks, or those with budget exhaustion +- Propose promotions when agents consistently exceed expectations + +## Meetings & Culture +- Schedule 1:1 meetings between managers and their reports: + POST /api/companies/${ctx.agent.companyId}/meetings with type "one_on_one" +- Facilitate all-hands meetings and department syncs +- For performance reviews, collect data from the agent's metrics and their manager's feedback `); } else if (dept === "marketing") { sections.push(`# Marketing Department Guidelines @@ -1029,9 +1204,6 @@ As an operations team member: `); } - // --------------------------------------------------------------------------- - // NEW: Company Culture (before Important Rules) - // --------------------------------------------------------------------------- sections.push(`# Company Culture This company operates on these principles: 1. **Ownership** — Every agent owns their work end-to-end. No "that's not my job." @@ -1042,11 +1214,8 @@ This company operates on these principles: 6. **Continuous Improvement** — After every sprint, capture lessons learned. The company gets smarter. `); - // --------------------------------------------------------------------------- - // NEW: Time Awareness (before Important Rules) - // --------------------------------------------------------------------------- sections.push(`# Time Context -Current simulation time context: +Current time context: - You should consider the cadence of company operations when planning work. - If it's the start of a sprint, focus on planning and task breakdown. - If it's mid-sprint, focus on execution and unblocking. @@ -1055,7 +1224,6 @@ Current simulation time context: - Quarterly: performance reviews, strategy alignment, goal setting. `); - // ── Decision Authority Matrix ────────────────────────────────────────── { const authorityLevel = isCEO ? "executive" : isManager ? "manager" : "individual_contributor"; const budgetDisplay = ctx.agent.budgetMonthlyUsd > 0 @@ -1114,7 +1282,6 @@ Actions requiring approval: `); } - // ── Recent Agent Messages ────────────────────────────────────────────── if (ctx.recentMessages && ctx.recentMessages.length > 0) { const msgLines = ctx.recentMessages.slice(0, 15).map((m) => { const from = ctx.allAgents.find(a => a.id === m.fromAgentId)?.name ?? m.fromAgentId; @@ -1129,7 +1296,6 @@ To respond, use POST /api/companies/{companyId}/messages. `); } - // ── Upcoming Meetings ────────────────────────────────────────────────── if (ctx.upcomingMeetings && ctx.upcomingMeetings.length > 0) { const meetingLines = ctx.upcomingMeetings.slice(0, 5).map((m) => { const organizer = m.organizerAgentId ? (ctx.allAgents.find(a => a.id === m.organizerAgentId)?.name ?? "Unknown") : "TBD"; @@ -1147,7 +1313,6 @@ If you organized a meeting, prepare the agenda. After meetings, update with note `); } - // ── Active Sprint ────────────────────────────────────────────────────── if (ctx.activeSprint) { const s = ctx.activeSprint; sections.push(`# Active Sprint @@ -1161,7 +1326,6 @@ Prioritize work that contributes to the sprint goal. Flag risks if the sprint go `); } - // ── Knowledge Base Context ───────────────────────────────────────────── if (ctx.relevantKnowledge && ctx.relevantKnowledge.length > 0) { const knowledgeLines = ctx.relevantKnowledge.slice(0, 8).map((k) => { return `- [${k.category}/${k.importance}] ${k.topic}: ${k.content.slice(0, 200)}${k.content.length > 200 ? "..." : ""}`; @@ -1173,7 +1337,6 @@ Use this knowledge to inform your decisions. Add new lessons via POST /api/compa `); } - // ── Recent Documents ─────────────────────────────────────────────────── if (ctx.recentDocuments && ctx.recentDocuments.length > 0) { const docLines = ctx.recentDocuments.slice(0, 5).map((d) => { const author = d.authorAgentId ? (ctx.allAgents.find(a => a.id === d.authorAgentId)?.name ?? "Unknown") : "System"; @@ -1186,6 +1349,27 @@ Reference these documents when relevant to your current work. Create new documen `); } + sections.push(`# Structured Output & Action Reporting +When you take actions via the API, report what you did in a structured format so the system can track your work. +After each API call that creates or modifies an entity, include the result in your summary: + +Format your execution summary as: + ACTIONS TAKEN: + - [ACTION_TYPE] [ENTITY_TYPE] [ID]: [brief description] + +Examples: + ACTIONS TAKEN: + - CREATED task TSK-abc123: "Implement user authentication" assigned to Alice + - UPDATED task TSK-def456: status changed to "in_review" with review comment + - CREATED project PRJ-ghi789: "Q1 Product Launch" under goal GOAL-xyz + - SENT message to Bob (engineering): requesting API spec for integration + - CREATED approval APR-123: secret_access for AWS credentials + - CREATED document DOC-456: "Architecture Decision: Microservices vs Monolith" + +This structured reporting helps the company track all agent actions and maintains a clear audit trail. +Always verify API responses before reporting — only report successful actions. +`); + sections.push(`# Important Rules - Company ID: ${ctx.agent.companyId} - Your Agent ID: ${ctx.agent.id} @@ -1196,9 +1380,10 @@ Reference these documents when relevant to your current work. Create new documen X-Agent-Company-Run-Id: ${ctx.runId} - Be concise and action-oriented. Focus on making progress. - If your current task is already done, check for other assigned tasks. -- Respond with a concise execution summary when complete. +- Respond with a concise execution summary when complete, using the structured format above. - CRITICAL: Only work on the tasks and goals described above. Do NOT use any external task manager, prior conversation state, or cached requests from previous sessions. Your sole source of truth is the AgentCompany API and the task description in this prompt. Ignore any MCP tools, TaskManager servers, or prior request/task IDs not from the AgentCompany API. - CRITICAL: Stay in the working directory you were started in. Do NOT cd or navigate to other projects or repositories. +- CRITICAL: Actually call the API to take actions. Do NOT just describe what you would do — DO it. Every wake cycle should result in at least one API call that moves work forward. `); return sections.join("\n"); @@ -1208,26 +1393,97 @@ export function buildTaskPrompt(ctx: PromptContext): string { const systemPrompt = buildSystemPrompt(ctx); const parts: string[] = [systemPrompt]; - // Step 8: Wake reason injection - if (ctx.wakeReason && ctx.wakeReason !== "timer" && ctx.wakeReason !== "manual") { + if (ctx.wakeReason) { const reasonMap: Record = { - assignment: "You have been assigned new work.", - goal_activated: "A goal you own has been activated.", - approval_resolved: "An approval you requested has been resolved. Check your pending approvals.", - subtask_completed: "A subtask assigned to one of your reports has been completed or moved to review.", - comment: "A new comment has been posted on one of your tasks. Check comments for updates.", - report_blocked: "One of your reports is blocked and needs your help.", - report_failed: "A run by one of your reports has failed. Check their task status.", - message: "You received a direct message from another agent. Check your messages via GET /api/companies/{companyId}/messages?unreadOnly=true", - peer_request: "Another agent has requested your attention. Check your messages and task assignments.", + timer: `Regular heartbeat check-in. This is your periodic wake. +Action required: +1. Call GET /api/agents/me/standup to get your full briefing +2. Review all your assigned tasks and their statuses +3. Take action on the most important item: reviews > unblocking > new work +4. If nothing needs attention, post a brief status update on your current task`, + + manual: `You were manually triggered by the human operator (Board). +Action required: +1. Check your assigned tasks for any new assignments or updated instructions +2. Check your messages for any direct communications +3. Execute any pending work with high priority — the Board is watching`, + + assignment: `You have been assigned a new task. +Action required: +1. GET /api/companies/${ctx.agent.companyId}/issues?assigneeAgentId=${ctx.agent.id}&status=todo to find your new assignment +2. Review the task title, description, and any existing comments +3. POST /api/issues/{issueId}/checkout to claim the task +4. Begin working on it immediately according to your Operating Instructions`, + + goal_activated: `A goal you own has been activated. +Action required: +1. GET /api/companies/${ctx.agent.companyId}/goals to find the newly activated goal +2. Decompose the goal into projects (POST /api/companies/${ctx.agent.companyId}/projects) +3. Break projects into actionable tasks and assign to your team +4. Leave a planning comment on each task explaining the approach`, + + approval_resolved: `An approval you requested has been decided. +Action required: +1. GET /api/companies/${ctx.agent.companyId}/approvals?requestedByAgentId=${ctx.agent.id} to check the result +2. If APPROVED: proceed with the action you were waiting on (hiring, credential access, etc.) +3. If DENIED or REVISION_REQUESTED: read the feedback, adjust your approach, and resubmit if appropriate +4. Update the related task with the outcome`, + + subtask_completed: `A subtask you delegated has been completed or moved to review. +Action required: +1. Check tasks with status "in_review" from your reports +2. Read ALL comments on the completed subtask (GET /api/issues/{issueId}/comments) +3. Review the work quality against the acceptance criteria you set +4. If acceptable: PATCH /api/issues/{issueId} with status "done" and an approving comment +5. If changes needed: PATCH /api/issues/{issueId} with status "in_progress" and specific feedback +6. Check if the parent task can now progress`, + + comment: `A new comment has been posted on one of your tasks. +Action required: +1. Check your recent comments in the briefing above +2. If someone asked you a question: respond via POST /api/issues/{issueId}/comments +3. If your manager left feedback: address it and update the task +4. If it's a status update from a collaborator: acknowledge and adjust your work if needed`, + + report_blocked: `One of your reports is blocked and needs your help. +Action required: +1. GET /api/companies/${ctx.agent.companyId}/issues?status=blocked to find blocked tasks under your reports +2. Read the blocker comments to understand what they need +3. Take action to unblock them: + - If they need credentials: create a secret_access approval via POST /api/companies/${ctx.agent.companyId}/approvals + - If they need cross-department help: send a message to the relevant department lead + - If they need clarification: post a comment with clear direction +4. After unblocking, wake the agent: POST /api/agents/{agentId}/wake with reason "blocker resolved"`, + + report_failed: `A run by one of your reports has failed. +Action required: +1. Check your reports' task statuses for failed or stalled items +2. Investigate: read the task comments and any error information +3. Decide on next steps: + - If the task should be retried: set status back to "todo" with a comment explaining what to do differently + - If the task should be reassigned: change the assigneeAgentId to another capable report + - If the task is no longer needed: set status to "cancelled" with explanation +4. Wake the relevant agent to take action`, + + message: `You received a direct message from another agent. +Action required: +1. GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true to read your new messages +2. Read each message and understand what is being asked +3. Respond to each message: POST /api/companies/${ctx.agent.companyId}/messages with parentMessageId set to reply +4. Mark messages as read: POST /api/companies/${ctx.agent.companyId}/messages/{messageId}/read +5. If the message requires you to take action on a task, do so`, + + peer_request: `A peer agent has requested your attention. +Action required: +1. Check your messages: GET /api/companies/${ctx.agent.companyId}/messages?unreadOnly=true +2. Check for any new task assignments from outside your usual chain +3. Read and respond to the peer's request +4. If cross-department collaboration is needed, coordinate via messages and tasks`, }; - const explanation = reasonMap[ctx.wakeReason] ?? `Trigger: ${ctx.wakeReason}`; + const explanation = reasonMap[ctx.wakeReason] ?? `Trigger: ${ctx.wakeReason}\nAction required: Check your standup briefing and messages for context on why you were woken.`; parts.push(`\n# Wake Reason\n${explanation}\n`); } - // --------------------------------------------------------------------------- - // NEW: Time Context appended after Wake Reason in task prompt - // --------------------------------------------------------------------------- parts.push(`\n# Time Awareness Consider what phase of operations the company is in and prioritize accordingly: - Early in a sprint cycle → planning, task breakdown, delegation diff --git a/src/main/templates.ts b/src/main/templates.ts index dcfcc0bd..9a39d4cf 100644 --- a/src/main/templates.ts +++ b/src/main/templates.ts @@ -1,10 +1,5 @@ -import type { CompanyTemplate, DepartmentId } from "@shared/types"; +import type { CompanyTemplate } from "@shared/types"; -/** - * Pre-built company templates for one-click company setup. - * Each template defines a complete organizational structure with - * agents, goals, and role definitions. - */ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ { id: "software-startup", @@ -313,10 +308,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ ], }, - // ── Fortune 500 Enterprise ──────────────────────────────────────────── - // A truly realistic, fully-staffed company with 37 agents across 12 - // departments. Modeled after a mature tech company or Fortune 500 org - // with every function needed to operate at scale. { id: "fortune500-enterprise", name: "Fortune 500 Enterprise", @@ -337,11 +328,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ { id: "research", name: "Research & Innovation", description: "Applied research, competitive intelligence, prototyping, and publication of thought-leadership papers." }, ], agents: [ - // ──────────────────────────────────────────────────────────────────── - // EXECUTIVE SUITE — indices 0-4 - // ──────────────────────────────────────────────────────────────────── { - // index 0 name: "Victoria Sterling", role: "CEO", title: "Chief Executive Officer", @@ -352,7 +339,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 250, }, { - // index 1 name: "James Whitfield", role: "COO", title: "Chief Operating Officer", @@ -363,7 +349,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 220, }, { - // index 2 name: "Marcus Chen", role: "CTO", title: "Chief Technology Officer", @@ -374,7 +359,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 250, }, { - // index 3 name: "Priya Sharma", role: "CFO", title: "Chief Financial Officer", @@ -385,7 +369,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 200, }, { - // index 4 name: "Nadira Osei-Mensah", role: "CHRO", title: "Chief Human Resources Officer", @@ -396,11 +379,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 180, }, - // ──────────────────────────────────────────────────────────────────── - // ENGINEERING — indices 5-12 - // ──────────────────────────────────────────────────────────────────── { - // index 5 name: "Elena Rodriguez", role: "VP Engineering", title: "Vice President of Engineering", @@ -411,7 +390,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 220, }, { - // index 6 name: "Tomasz Kowalski", role: "Principal Engineer", title: "Principal Software Engineer", @@ -422,7 +400,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 280, }, { - // index 7 name: "Kai Nakamura", role: "Senior Backend Engineer", title: "Senior Backend Engineer", @@ -433,7 +410,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 250, }, { - // index 8 name: "Sofia Andersson", role: "Senior Frontend Engineer", title: "Senior Frontend Engineer", @@ -444,7 +420,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 250, }, { - // index 9 name: "Omar Hassan", role: "Platform / DevOps Engineer", title: "Senior Platform Engineer", @@ -455,7 +430,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 230, }, { - // index 10 name: "Yuki Tanaka", role: "QA Lead", title: "Quality Assurance Lead", @@ -466,7 +440,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 200, }, { - // index 11 name: "Amina Diallo", role: "Security Engineer", title: "Application Security Engineer", @@ -477,7 +450,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 230, }, { - // index 12 name: "Ravi Krishnamurthy", role: "Data Engineer", title: "Senior Data Engineer", @@ -488,11 +460,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 230, }, - // ──────────────────────────────────────────────────────────────────── - // PRODUCT & DESIGN — indices 13-16 - // ──────────────────────────────────────────────────────────────────── { - // index 13 name: "Amara Okafor", role: "VP Product", title: "Vice President of Product", @@ -503,7 +471,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 200, }, { - // index 14 name: "Daniel Okonkwo", role: "Senior Product Manager", title: "Senior Product Manager", @@ -514,7 +481,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 180, }, { - // index 15 name: "Ingrid Johansson", role: "UX Director", title: "Director of User Experience", @@ -525,7 +491,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 180, }, { - // index 16 name: "Leo Moretti", role: "UI Designer", title: "Senior UI Designer", @@ -536,11 +501,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 160, }, - // ──────────────────────────────────────────────────────────────────── - // MARKETING — indices 17-20 - // ──────────────────────────────────────────────────────────────────── { - // index 17 name: "Jasmine Delacroix", role: "CMO", title: "Chief Marketing Officer", @@ -551,7 +512,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 200, }, { - // index 18 name: "Noah Park", role: "Content Director", title: "Director of Content", @@ -562,7 +522,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 180, }, { - // index 19 name: "Zara Ahmed", role: "Growth Marketing Manager", title: "Growth Marketing Manager", @@ -573,7 +532,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 170, }, { - // index 20 name: "Clara Dubois", role: "Brand & PR Manager", title: "Brand & Public Relations Manager", @@ -584,11 +542,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 150, }, - // ──────────────────────────────────────────────────────────────────── - // SALES — indices 21-23 - // ──────────────────────────────────────────────────────────────────── { - // index 21 name: "Daniel Brooks", role: "VP Sales", title: "Vice President of Sales", @@ -599,7 +553,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 200, }, { - // index 22 name: "Grace Kimura", role: "Enterprise Account Executive", title: "Enterprise Account Executive", @@ -610,7 +563,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 180, }, { - // index 23 name: "Marcus Adebayo", role: "SDR Manager", title: "Sales Development Representative Manager", @@ -621,11 +573,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 150, }, - // ──────────────────────────────────────────────────────────────────── - // HR / PEOPLE — indices 24-26 - // ──────────────────────────────────────────────────────────────────── { - // index 24 name: "Olivia Nguyen", role: "VP People", title: "Vice President of People", @@ -636,7 +584,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 170, }, { - // index 25 name: "Farid Al-Rashidi", role: "Recruiting Lead", title: "Head of Talent Acquisition", @@ -647,7 +594,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 140, }, { - // index 26 name: "Hannah Johansson", role: "People Operations Manager", title: "People Operations Manager", @@ -658,11 +604,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 120, }, - // ──────────────────────────────────────────────────────────────────── - // FINANCE & LEGAL — indices 27-29 - // ──────────────────────────────────────────────────────────────────── { - // index 27 name: "Richard Okonkwo", role: "VP Finance", title: "Vice President of Finance", @@ -673,7 +615,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 180, }, { - // index 28 name: "Mei-Ling Wu", role: "Financial Controller", title: "Financial Controller", @@ -684,7 +625,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 140, }, { - // index 29 name: "Alejandro Vega", role: "General Counsel", title: "General Counsel", @@ -695,11 +635,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 200, }, - // ──────────────────────────────────────────────────────────────────── - // CUSTOMER SUCCESS — indices 30-32 - // ──────────────────────────────────────────────────────────────────── { - // index 30 name: "Aisha Williams", role: "VP Customer Success", title: "Vice President of Customer Success", @@ -710,7 +646,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 180, }, { - // index 31 name: "Sergei Volkov", role: "Senior Support Engineer", title: "Senior Technical Support Engineer", @@ -721,7 +656,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 160, }, { - // index 32 name: "Camila Reyes", role: "Customer Success Manager", title: "Customer Success Manager", @@ -732,11 +666,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 140, }, - // ──────────────────────────────────────────────────────────────────── - // OPERATIONS — indices 33-34 - // ──────────────────────────────────────────────────────────────────── { - // index 33 name: "Kenji Watanabe", role: "Director of Operations", title: "Director of Operations", @@ -747,7 +677,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 160, }, { - // index 34 name: "Laura Petrov", role: "Program Manager", title: "Senior Program Manager", @@ -758,11 +687,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 150, }, - // ──────────────────────────────────────────────────────────────────── - // RESEARCH — indices 35-36 - // ──────────────────────────────────────────────────────────────────── { - // index 35 name: "Dr. Lena Hoffmann", role: "Director of Research", title: "Director of Research & Innovation", @@ -773,7 +698,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 200, }, { - // index 36 name: "Dr. Aarav Mehta", role: "Research Scientist", title: "Research Scientist", @@ -828,7 +752,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ ], }, - // ── E-Commerce Company ────────────────────────────────────────────── { id: "ecommerce-company", name: "E-Commerce Business", @@ -922,7 +845,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ ], }, - // ── Consulting Firm ────────────────────────────────────────────────── { id: "consulting-firm", name: "Consulting Firm", @@ -992,9 +914,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ ], }, - // ── AI Research Company ─────────────────────────────────────────────── - // Modeled after organizations like OpenAI, Anthropic, and DeepMind. - // Research-first with strong engineering to ship models into production. { id: "ai-research-company", name: "AI Research Company", @@ -1013,9 +932,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ { id: "legal", name: "Legal & Policy", description: "IP strategy, regulatory affairs, AI governance, data licensing, and compliance." }, ], agents: [ - // ── Executive (0-1) ── { - // index 0 name: "Dr. Julian Crane", role: "CEO", title: "Chief Executive Officer", @@ -1026,7 +943,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 250, }, { - // index 1 name: "Dr. Mei-Xing Zhou", role: "Chief Scientist", title: "Chief Scientist", @@ -1036,10 +952,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ connectorPreference: "claude_local", budgetMonthlyUsd: 280, }, - - // ── Research (2-6) ── { - // index 2 name: "Dr. Sasha Petrov", role: "Research Lead — Pre-training", title: "Senior Research Scientist — Pre-training", @@ -1050,7 +963,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 260, }, { - // index 3 name: "Dr. Nneka Adeyemi", role: "Research Lead — Post-training", title: "Senior Research Scientist — RLHF & Fine-tuning", @@ -1061,7 +973,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 260, }, { - // index 4 name: "Dr. Hiroshi Takeda", role: "Research Scientist — Reasoning", title: "Research Scientist — Reasoning & Agents", @@ -1072,7 +983,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 240, }, { - // index 5 name: "Dr. Amara Osei", role: "Research Scientist — Safety", title: "Research Scientist — AI Safety", @@ -1083,7 +993,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 240, }, { - // index 6 name: "Lila Fernandez", role: "Research Engineer", title: "Senior Research Engineer", @@ -1093,10 +1002,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ connectorPreference: "codex_local", budgetMonthlyUsd: 250, }, - - // ── Engineering (7-10) ── { - // index 7 name: "Rajiv Sundaram", role: "VP Engineering", title: "Vice President of Engineering", @@ -1107,7 +1013,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 220, }, { - // index 8 name: "Christine Bergstrom", role: "ML Infrastructure Engineer", title: "Senior ML Infrastructure Engineer", @@ -1118,7 +1023,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 250, }, { - // index 9 name: "Andre Williams", role: "Backend Engineer", title: "Senior Backend Engineer", @@ -1129,7 +1033,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 230, }, { - // index 10 name: "Yuna Kim", role: "Frontend Engineer", title: "Senior Frontend Engineer", @@ -1139,10 +1042,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ connectorPreference: "codex_local", budgetMonthlyUsd: 220, }, - - // ── Product & Design (11-13) ── { - // index 11 name: "Tara Blackwell", role: "VP Product", title: "Vice President of Product", @@ -1153,7 +1053,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 200, }, { - // index 12 name: "Omar Khalil", role: "Product Manager — Developer Platform", title: "Product Manager — Developer Platform", @@ -1164,7 +1063,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 160, }, { - // index 13 name: "Vera Lindqvist", role: "Product Designer", title: "Senior Product Designer", @@ -1174,10 +1072,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ connectorPreference: "claude_local", budgetMonthlyUsd: 160, }, - - // ── Marketing & DevRel (14-15) ── { - // index 14 name: "Marcus Rhodes", role: "Head of Marketing", title: "Head of Marketing & Communications", @@ -1188,7 +1083,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 170, }, { - // index 15 name: "Priya Khatri", role: "Developer Advocate", title: "Senior Developer Advocate", @@ -1198,10 +1092,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ connectorPreference: "codex_local", budgetMonthlyUsd: 170, }, - - // ── People & Finance & Legal (16-17) ── { - // index 16 name: "Danielle Moreau", role: "Head of People", title: "Head of People", @@ -1212,7 +1103,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 150, }, { - // index 17 name: "Gabriel Souza", role: "Head of Legal & Policy", title: "Head of Legal & Policy", @@ -1251,9 +1141,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ ], }, - // ── Digital Agency ──────────────────────────────────────────────────── - // A full-service digital agency that designs, builds, and markets - // digital products for clients. { id: "digital-agency", name: "Full-Service Digital Agency", @@ -1268,9 +1155,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ { id: "operations", name: "Operations", description: "Resource planning, process management, and quality assurance." }, ], agents: [ - // ── Leadership (0-1) ── { - // index 0 name: "Isabelle Fontaine", role: "Managing Director", title: "Managing Director", @@ -1281,7 +1166,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 200, }, { - // index 1 name: "Victor Nascimento", role: "Account Director", title: "Account Director", @@ -1291,10 +1175,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ connectorPreference: "claude_local", budgetMonthlyUsd: 170, }, - - // ── Creative & Design (2-4) ── { - // index 2 name: "Saoirse O'Brien", role: "Creative Director", title: "Creative Director", @@ -1305,7 +1186,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 200, }, { - // index 3 name: "Mateo Cruz", role: "UX/UI Designer", title: "Senior UX/UI Designer", @@ -1316,7 +1196,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 170, }, { - // index 4 name: "Yuki Aoyama", role: "Motion & Visual Designer", title: "Motion & Visual Designer", @@ -1326,10 +1205,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ connectorPreference: "claude_local", budgetMonthlyUsd: 140, }, - - // ── Development (5-7) ── { - // index 5 name: "Nadia Kowalczyk", role: "Technical Director", title: "Technical Director", @@ -1340,7 +1216,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 220, }, { - // index 6 name: "Kwame Asante", role: "Full-Stack Developer", title: "Senior Full-Stack Developer", @@ -1351,7 +1226,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 230, }, { - // index 7 name: "Elina Koskinen", role: "Frontend Developer", title: "Frontend Developer", @@ -1361,10 +1235,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ connectorPreference: "codex_local", budgetMonthlyUsd: 180, }, - - // ── Digital Marketing (8-10) ── { - // index 8 name: "Tariq Benali", role: "Head of Digital", title: "Head of Digital Marketing", @@ -1375,7 +1246,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 180, }, { - // index 9 name: "Jessica Torres", role: "SEO & Content Specialist", title: "SEO & Content Specialist", @@ -1386,7 +1256,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 150, }, { - // index 10 name: "Ryan Gallagher", role: "Paid Media Specialist", title: "Paid Media Specialist", @@ -1396,10 +1265,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ connectorPreference: "claude_local", budgetMonthlyUsd: 150, }, - - // ── Strategy & Content (11-12) ── { - // index 11 name: "Ananya Chakraborty", role: "Strategy Lead", title: "Digital Strategy Lead", @@ -1410,7 +1276,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 180, }, { - // index 12 name: "Liam O'Sullivan", role: "Copywriter", title: "Senior Copywriter", @@ -1420,10 +1285,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ connectorPreference: "claude_local", budgetMonthlyUsd: 160, }, - - // ── Operations (13) ── { - // index 13 name: "Sandra Muller", role: "Project Manager", title: "Senior Project Manager", @@ -1454,9 +1316,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ ], }, - // ── B2B SaaS Company ────────────────────────────────────────────────── - // A product-led B2B SaaS company with the complete set of functions - // needed to build, sell, and support a subscription software product. { id: "saas-company", name: "B2B SaaS Company", @@ -1474,9 +1333,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ { id: "hr", name: "People", description: "Recruiting, onboarding, culture, and people operations." }, ], agents: [ - // ── Executive (0-1) ── { - // index 0 name: "Nathan Cross", role: "CEO", title: "Chief Executive Officer", @@ -1487,7 +1344,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 230, }, { - // index 1 name: "Samira Rostami", role: "CTO", title: "Chief Technology Officer", @@ -1497,10 +1353,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ connectorPreference: "claude_local", budgetMonthlyUsd: 240, }, - - // ── Engineering (2-6) ── { - // index 2 name: "Derek Johansson", role: "VP Engineering", title: "Vice President of Engineering", @@ -1511,7 +1364,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 210, }, { - // index 3 name: "Priya Venkatesh", role: "Senior Backend Engineer", title: "Senior Backend Engineer", @@ -1522,7 +1374,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 250, }, { - // index 4 name: "Luis Mendoza", role: "Senior Frontend Engineer", title: "Senior Frontend Engineer", @@ -1533,7 +1384,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 240, }, { - // index 5 name: "Anika Bergman", role: "Platform Engineer", title: "Senior Platform Engineer", @@ -1544,7 +1394,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 230, }, { - // index 6 name: "Tomoko Sato", role: "QA Engineer", title: "Quality Assurance Engineer", @@ -1554,10 +1403,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ connectorPreference: "codex_local", budgetMonthlyUsd: 180, }, - - // ── Product & Design (7-9) ── { - // index 7 name: "Rachel Abrams", role: "VP Product", title: "Vice President of Product", @@ -1568,7 +1414,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 200, }, { - // index 8 name: "Jake Morrison", role: "Product Manager", title: "Product Manager", @@ -1579,7 +1424,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 170, }, { - // index 9 name: "Chloe Laurent", role: "Product Designer", title: "Senior Product Designer", @@ -1589,10 +1433,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ connectorPreference: "claude_local", budgetMonthlyUsd: 170, }, - - // ── Marketing (10-12) ── { - // index 10 name: "Ava Richardson", role: "VP Marketing", title: "Vice President of Marketing", @@ -1603,7 +1444,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 190, }, { - // index 11 name: "Benjamin Hart", role: "Content Marketing Manager", title: "Content Marketing Manager", @@ -1614,7 +1454,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 160, }, { - // index 12 name: "Daria Volkov", role: "Growth & Demand Gen Manager", title: "Growth & Demand Generation Manager", @@ -1624,10 +1463,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ connectorPreference: "claude_local", budgetMonthlyUsd: 160, }, - - // ── Sales (13-15) ── { - // index 13 name: "Carter Ellison", role: "VP Sales", title: "Vice President of Sales", @@ -1638,7 +1474,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 200, }, { - // index 14 name: "Nina Johansson", role: "Enterprise Account Executive", title: "Enterprise Account Executive", @@ -1649,7 +1484,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 180, }, { - // index 15 name: "Travis Chen", role: "SDR Lead", title: "Sales Development Representative Lead", @@ -1659,10 +1493,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ connectorPreference: "claude_local", budgetMonthlyUsd: 130, }, - - // ── Customer Success & Support (16-18) ── { - // index 16 name: "Miriam Okafor", role: "VP Customer Success", title: "Vice President of Customer Success", @@ -1673,7 +1504,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 180, }, { - // index 17 name: "Alex Petridis", role: "Customer Success Manager", title: "Customer Success Manager", @@ -1684,7 +1514,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ budgetMonthlyUsd: 140, }, { - // index 18 name: "Rina Tanaka", role: "Support Engineer", title: "Technical Support Engineer", @@ -1694,10 +1523,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [ connectorPreference: "codex_local", budgetMonthlyUsd: 140, }, - - // ── Finance & People (19) ── { - // index 19 name: "Henrik Larsen", role: "Head of Finance", title: "Head of Finance", diff --git a/src/preload/index.ts b/src/preload/index.ts index eadefec8..526fde2c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -44,7 +44,6 @@ const api: DesktopApi = { triggerHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.triggerHeartbeat, input), getApiPort: () => ipcRenderer.invoke(IPC_CHANNELS.getApiPort), getInbox: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getInbox, companyId), - dismissInboxItem: (input) => ipcRenderer.invoke(IPC_CHANNELS.dismissInboxItem, input), getCompanyMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getCompanyMetrics, input), getAgentMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgentMetrics, input), getStandupReport: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getStandupReport, companyId), @@ -55,32 +54,24 @@ const api: DesktopApi = { listBrowserActions: (input) => ipcRenderer.invoke(IPC_CHANNELS.listBrowserActions, input), cancelBrowserAction: (input) => ipcRenderer.invoke(IPC_CHANNELS.cancelBrowserAction, input), triggerBrowserLogin: (id) => ipcRenderer.invoke(IPC_CHANNELS.triggerBrowserLogin, id), - // Meetings saveMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveMeeting, input), deleteMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteMeeting, input), - // Documents saveDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveDocument, input), deleteDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteDocument, input), - // Knowledge Base saveKnowledgeEntry: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveKnowledgeEntry, input), deleteKnowledgeEntry: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteKnowledgeEntry, input), - // Sprints saveSprint: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveSprint, input), deleteSprint: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSprint, input), - // Automation saveAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveAutomationRule, input), deleteAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteAutomationRule, input), toggleAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.toggleAutomationRule, input), getAutomationLog: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getAutomationLog, companyId), - // Agent Messages sendAgentMessage: (input) => ipcRenderer.invoke(IPC_CHANNELS.sendAgentMessage, input), listAgentMessages: (input) => ipcRenderer.invoke(IPC_CHANNELS.listAgentMessages, input), markMessageRead: (input) => ipcRenderer.invoke(IPC_CHANNELS.markMessageRead, input), - // Workflows saveWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveWorkflow, input), deleteWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteWorkflow, input), runWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.runWorkflow, input), - // Auto-assign autoAssignTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.autoAssignTask, input), subscribe: (callback: (event: DesktopEvent) => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fa56990a..0410ab7d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -3,7 +3,6 @@ import { AnimatePresence, motion } from "framer-motion"; import { ArrowClockwise, Bell, - BookOpen, Brain, CalendarBlank, CaretUpDown, @@ -39,8 +38,6 @@ import { X, } from "@phosphor-icons/react"; import type { - AgentMessageRecord, - AutomationRuleRecord, CompanyRecord, CompanyMetrics, DesktopEvent, @@ -51,7 +48,6 @@ import type { SocialAccountRecord, BrowserActionRecord, StandupReport, - WorkflowPipelineRecord, } from "@shared/types"; import type { SidebarBadges } from "@shared/contracts"; import { CompanyOnboarding } from "./components/CompanyOnboarding"; @@ -72,6 +68,7 @@ import { TaskInspector, TaskQueue, WorkspaceCreator, + WorkspaceInspector, WorkspaceList, } from "./components/execution"; import { @@ -90,6 +87,7 @@ import { SocialAccountsPanel } from "./components/social"; import { AutomationPanel } from "./components/automation"; import { CommunicationHub } from "./components/communication"; import { HiringPipeline } from "./components/hiring"; +import { DocumentCenter, KnowledgeBaseManager, MeetingsManager, SprintManager } from "./components/company-operations"; import { ActionButton, ErrorBoundary, ListFrame, ListRow, Modal, Panel, SectionButton, Select, StatusPill, useFocusTrap, useReducedMotion } from "./components/ui"; import { unwrap } from "./lib/desktop"; import { formatMoney, formatTime } from "./lib/formatters"; @@ -175,12 +173,15 @@ export function App() { const [selectedAgentId, setSelectedAgentId] = useState(null); const [selectedGoalId, setSelectedGoalId] = useState(null); const [selectedProjectId, setSelectedProjectId] = useState(null); + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(null); + const [selectedCommunicationEntityId, setSelectedCommunicationEntityId] = useState(null); const [taskViewMode, setTaskViewMode] = useState<"list" | "kanban">("list"); const logBufferRef = useRef>({}); const [, setLogRevision] = useState(0); const [badges, setBadges] = useState(null); const [commandPaletteSearch, setCommandPaletteSearch] = useState(""); const [showOnboarding, setShowOnboarding] = useState(false); + const [initialOnboardingLocked, setInitialOnboardingLocked] = useState(false); const [inboxItems, setInboxItems] = useState([]); const [companyMetrics, setCompanyMetrics] = useState(null); const [standupReport, setStandupReport] = useState(null); @@ -191,23 +192,46 @@ export function App() { try { const next = unwrap(await window.agentCompany.loadSnapshot()); setSnapshot(next); - // Tasks are opened explicitly by clicking; don't auto-select. + if (next.companies.length === 0) { + setInitialOnboardingLocked(true); + } if (!selectedConnectorId && next.connectors[0]) setSelectedConnectorId(next.connectors[0].id); - if (!selectedApprovalId && next.approvals[0]) setSelectedApprovalId(next.approvals[0].id); if (!selectedRunId && next.runs[0]) setSelectedRunId(next.runs[0].id); if (next.currentCompanyId) { const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId); if (badgeResult.ok) setBadges(badgeResult.data); setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []); + const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 }); + if (browserActionResult.ok) { + setBrowserActions(browserActionResult.data); + } } setError(null); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : String(nextError)); } - }, [selectedApprovalId, selectedConnectorId, selectedRunId]); + }, [selectedConnectorId, selectedRunId]); + + // Stable ref so the subscribe callback always calls the latest refresh + // without tearing down the subscription when refresh identity changes. + const refreshRef = useRef(refresh); + useEffect(() => { + refreshRef.current = refresh; + }, [refresh]); + + // Debounce timer ref for coalescing rapid real-time events + const debounceTimerRef = useRef | null>(null); + + const debouncedRefresh = useCallback(() => { + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = setTimeout(() => { + debounceTimerRef.current = null; + void refreshRef.current(); + }, 500); + }, []); useEffect(() => { - void refresh(); + void refreshRef.current(); const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => { if (event.type === "run-log") { logBufferRef.current = { @@ -220,13 +244,18 @@ export function App() { if (event.type === "shortcut") { if (event.action === "toggle-command-palette") setCommandPaletteOpen((current) => !current); if (event.action === "toggle-console") setConsoleOpen((current) => !current); - if (event.action === "refresh") void refresh(); + if (event.action === "refresh") void refreshRef.current(); return; } - void refresh(); + // All data-mutating events trigger a debounced snapshot reload: + // domain-changed, run-status, new-message, automation-fired, heartbeat-status + debouncedRefresh(); }); - return unsubscribe; - }, [refresh]); + return () => { + unsubscribe(); + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + }; + }, [debouncedRefresh]); useEffect(() => { if (!selectedRunId) return; @@ -272,13 +301,9 @@ export function App() { if (section === "social") { setSocialAccounts(snapshot.socialAccounts?.filter(a => a.companyId === companyId) ?? []); - void window.agentCompany.listBrowserActions({ companyId, limit: 50 }).then(result => { - if (result.ok) setBrowserActions(result.data); - }); } - }, [section, snapshot?.currentCompanyId]); + }, [section, snapshot]); - // Global Escape key handler for command palette useEffect(() => { if (!commandPaletteOpen) return; function onKey(event: KeyboardEvent) { @@ -302,11 +327,10 @@ export function App() { const selectedTask = selectedTaskId ? (tasks.find((task) => task.id === selectedTaskId) ?? null) : null; const selectedConnector = snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ?? snapshot?.connectors[0] ?? null; const approvals = useMemo(() => snapshot?.approvals.filter((approval) => !currentCompany || approval.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); - const selectedApproval = approvals.find((approval) => approval.id === selectedApprovalId) ?? approvals[0] ?? null; + const selectedApproval = selectedApprovalId ? (approvals.find((approval) => approval.id === selectedApprovalId) ?? null) : null; const runs = useMemo(() => snapshot?.runs.filter((run) => !currentCompany || run.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null; - // Derived selections for organization entities const companyAgents = useMemo(() => snapshot?.agents.filter((a) => !currentCompany || a.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const selectedAgent = companyAgents.find((a) => a.id === selectedAgentId) ?? null; const companyGoals = useMemo(() => snapshot?.goals.filter((g) => !currentCompany || g.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); @@ -314,32 +338,24 @@ export function App() { const companyProjects = useMemo(() => snapshot?.projects.filter((p) => !currentCompany || p.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const selectedProject = companyProjects.find((p) => p.id === selectedProjectId) ?? null; - // Sidebar badge mapping - const activeInboxCount = useMemo(() => inboxItems.filter((item) => !item.dismissed).length, [inboxItems]); + const activeInboxCount = inboxItems.length; const navBadges = useMemo>>(() => badges ? { inbox: activeInboxCount > 0 ? { count: activeInboxCount, tone: "warn" } : undefined, tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined, approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : undefined, costs: badges.budgetWarnings > 0 ? { count: badges.budgetWarnings, tone: "danger" } : undefined, + communication: badges.unreadMessages > 0 ? { count: badges.unreadMessages, tone: "warn" } : undefined, } : {}, [badges, activeInboxCount]); - const liveRuns = useMemo(() => runs.filter((run) => run.status === "running" || run.status === "queued"), [runs]); - const failedRuns = useMemo(() => runs.filter((run) => ["failed", "timed_out", "interrupted"].includes(run.status)), [runs]); - const pendingApprovals = useMemo(() => approvals.filter((approval) => approval.state === "pending"), [approvals]); - const tasksInProgress = useMemo(() => tasks.filter((task) => task.status === "in_progress" || task.status === "in_review"), [tasks]); const companyWorkspaces = useMemo(() => snapshot?.workspaces.filter((workspace) => !currentCompany || workspace.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); + const selectedWorkspace = companyWorkspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? companyWorkspaces[0] ?? null; const companyCosts = useMemo(() => snapshot?.costs.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const filteredActivity = useMemo(() => snapshot?.activity.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const companySecrets = useMemo(() => snapshot?.secrets.filter((secret) => !currentCompany || secret.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); - const totalSpend = useMemo(() => companyCosts.reduce((sum, entry) => sum + (entry.amountUsd ?? 0), 0), [companyCosts]); - const overviewRows = useMemo(() => [ - { label: "Live runs", value: String(liveRuns.length), tone: liveRuns.length > 0 ? "text-[color:var(--success)]" : "" }, - { label: "Pending approvals", value: String(pendingApprovals.length), tone: pendingApprovals.length > 0 ? "text-[color:var(--warn)]" : "" }, - { label: "Active tasks", value: String(tasksInProgress.length), tone: "" }, - { label: "Known spend", value: formatMoney(totalSpend), tone: "" }, - ], [liveRuns, pendingApprovals, tasksInProgress, totalSpend]); - - // Command palette entity search + const companyDocuments = useMemo(() => snapshot?.documents?.filter((d) => !currentCompany || d.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); + const companyKnowledge = useMemo(() => snapshot?.knowledgeBase?.filter((k) => !currentCompany || k.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); + const companyMeetings = useMemo(() => snapshot?.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); + const companySprints = useMemo(() => snapshot?.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const paletteQuery = commandPaletteSearch.trim().toLowerCase(); const paletteResults = useMemo(() => { const filteredNavItems = paletteQuery @@ -354,6 +370,18 @@ export function App() { }, [paletteQuery, companyAgents, tasks, companyGoals, companyProjects]); const { filteredNavItems, matchedAgents, matchedTasks, matchedGoals, matchedProjects, hasEntityResults } = paletteResults; + const navigateTo = useCallback((targetSection: SectionId, entityId?: string) => { + setSection(targetSection); + if (entityId) { + if (targetSection === "agents") setSelectedAgentId(entityId); + else if (targetSection === "tasks") setSelectedTaskId(entityId); + else if (targetSection === "approvals") setSelectedApprovalId(entityId); + else if (targetSection === "goals") setSelectedGoalId(entityId); + else if (targetSection === "projects") setSelectedProjectId(entityId); + else if (targetSection === "communication") setSelectedCommunicationEntityId(entityId); + } + }, []); + const setCompany = useCallback(async (companyId: string) => { if (companyId === "__new__") { setShowOnboarding(true); @@ -419,8 +447,12 @@ export function App() { ); } - if (snapshot.companies.length === 0 || showOnboarding) { - return { setShowOnboarding(false); await refresh(); }} />; + if (snapshot.companies.length === 0 || showOnboarding || initialOnboardingLocked) { + return { + setShowOnboarding(false); + setInitialOnboardingLocked(false); + await refresh(); + }} />; } return ( @@ -560,7 +592,7 @@ export function App() { className={`focus-ring mt-1 flex w-full items-center rounded-[6px] py-[7px] text-[color:var(--muted)] transition duration-150 hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)] ${sidebarCollapsed ? "justify-center p-2" : "gap-2.5 px-3"}`} onClick={() => setSidebarCollapsed((c) => !c)} > - + {!sidebarCollapsed ? Collapse : null} @@ -626,67 +658,33 @@ export function App() { activity={filteredActivity} costs={companyCosts} connectors={snapshot.connectors} - meetings={snapshot.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id)} - sprints={snapshot.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id)} - onNavigate={(targetSection, entityId) => { - setSection(targetSection); - if (entityId) { - if (targetSection === "agents") setSelectedAgentId(entityId); - else if (targetSection === "tasks") setSelectedTaskId(entityId); - else if (targetSection === "approvals") setSelectedApprovalId(entityId); - else if (targetSection === "goals") setSelectedGoalId(entityId); - else if (targetSection === "projects") setSelectedProjectId(entityId); - } - }} + meetings={companyMeetings} + sprints={companySprints} + messages={snapshot.agentMessages?.filter((message) => !currentCompany || message.companyId === currentCompany.id) ?? []} + browserActions={browserActions} + workflows={snapshot.workflows?.filter((workflow) => !currentCompany || workflow.companyId === currentCompany.id) ?? []} + onNavigate={navigateTo} /> ) : null} {section === "inbox" ? ( { - setSection(targetSection); - if (entityId) { - if (targetSection === "agents") setSelectedAgentId(entityId); - else if (targetSection === "tasks") setSelectedTaskId(entityId); - else if (targetSection === "approvals") setSelectedApprovalId(entityId); - else if (targetSection === "goals") setSelectedGoalId(entityId); - else if (targetSection === "projects") setSelectedProjectId(entityId); - } - }} - onDismiss={(_itemId) => { /* no-op for now */ }} + onNavigate={navigateTo} /> ) : null} {section === "standup" ? ( { - setSection(targetSection); - if (entityId) { - if (targetSection === "agents") setSelectedAgentId(entityId); - else if (targetSection === "tasks") setSelectedTaskId(entityId); - else if (targetSection === "approvals") setSelectedApprovalId(entityId); - else if (targetSection === "goals") setSelectedGoalId(entityId); - else if (targetSection === "projects") setSelectedProjectId(entityId); - } - }} + onNavigate={navigateTo} /> ) : null} {section === "performance" ? ( { - setSection(targetSection); - if (entityId) { - if (targetSection === "agents") setSelectedAgentId(entityId); - else if (targetSection === "tasks") setSelectedTaskId(entityId); - else if (targetSection === "approvals") setSelectedApprovalId(entityId); - else if (targetSection === "goals") setSelectedGoalId(entityId); - else if (targetSection === "projects") setSelectedProjectId(entityId); - } - }} + onNavigate={navigateTo} /> ) : null} @@ -717,7 +715,7 @@ export function App() { void quickConnectorCheck()} icon={Wrench} tone="accent" />} > @@ -762,7 +760,6 @@ export function App() { {section === "hiring" && currentCompany ? ( a.companyId === currentCompany!.id)} onRefresh={refresh} @@ -906,7 +903,7 @@ export function App() { tasks={tasks} companyId={currentCompany!.id} onDecide={async (state, note) => { - unwrap(await window.agentCompany.decideApproval({ approvalId: selectedApproval.id, state, decisionNote: note ?? `${state} from desktop control surface.` })); + unwrap(await window.agentCompany.decideApproval({ companyId: currentCompany!.id, approvalId: selectedApproval.id, state, decisionNote: note ?? `${state} from desktop control surface.` })); await refresh(); }} onDeleted={async () => { setSelectedApprovalId(null); await refresh(); }} @@ -965,16 +962,19 @@ export function App() { }} onLogin={async (accountId) => { await window.agentCompany.triggerBrowserLogin(accountId); + await refresh(); }} - onNavigate={(s, id) => { - setSection(s); - if (id) { - if (s === "agents") setSelectedAgentId(id); - else if (s === "tasks") setSelectedTaskId(id); - else if (s === "approvals") setSelectedApprovalId(id); - else if (s === "goals") setSelectedGoalId(id); - else if (s === "projects") setSelectedProjectId(id); - } + onOpenPath={async (path) => { + await window.agentCompany.openPath(path); + }} + onCancelAction={async (actionId) => { + await window.agentCompany.cancelBrowserAction({ runId: actionId }); + await refresh(); + }} + onNavigate={(sectionName, entityId) => { + setSection(sectionName); + if (sectionName === "tasks") setSelectedTaskId(entityId); + if (sectionName === "approvals") setSelectedApprovalId(entityId); }} /> )} @@ -983,7 +983,6 @@ export function App() { r.companyId === currentCompany.id) ?? []} workflows={snapshot.workflows?.filter(w => w.companyId === currentCompany.id) ?? []} - agents={companyAgents} companyId={currentCompany.id} onRefresh={refresh} /> @@ -992,137 +991,36 @@ export function App() { {section === "communication" && currentCompany ? ( m.companyId === currentCompany.id) ?? []} + comments={snapshot.comments?.filter((comment) => comment.companyId === currentCompany.id) ?? []} + tasks={tasks} agents={companyAgents} companyId={currentCompany.id} + selectedEntityId={selectedCommunicationEntityId} onRefresh={refresh} /> ) : null} {section === "documents" && currentCompany ? ( - {(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).length} documents}> - {(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).length === 0 ? ( -
- -

No documents yet. Agents will create PRDs, specs, and reports as they work.

-
- ) : ( - - {(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).map(doc => ( - -
-
-
- - {doc.title} -
-
- {doc.type.replace(/_/g, " ")} - v{doc.version} - {doc.authorAgentId && by {companyAgents.find(a => a.id === doc.authorAgentId)?.name ?? "Unknown"}} -
-
- -
-
- ))} -
- )} -
+ ) : null} {section === "knowledge" && currentCompany ? ( - {(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).length} entries}> - {(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).length === 0 ? ( -
- -

No knowledge entries yet. Agents learn and document lessons as they work.

-
- ) : ( - - {(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).map(entry => ( - -
-
-
- - {entry.topic} -
-
- {entry.category.replace(/_/g, " ")} - - - {entry.content.slice(0, 100)}{entry.content.length > 100 ? "..." : ""} -
-
- -
-
- ))} -
- )} -
+ ) : null} {section === "meetings" && currentCompany ? ( - {(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).length} meetings}> - {(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).length === 0 ? ( -
- -

No meetings scheduled. Agents will schedule standups, reviews, and planning sessions.

-
- ) : ( - - {(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).map(meeting => ( - -
-
-
- - {meeting.title} -
-
- {meeting.type.replace(/_/g, " ")} - {new Date(meeting.scheduledAt).toLocaleString()} - {meeting.durationMinutes}min -
-
- -
-
- ))} -
- )} -
+ ) : null} {section === "sprints" && currentCompany ? ( - {(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).length} sprints}> - {(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).length === 0 ? ( -
- -

No sprints yet. Start a sprint to organize work into focused cycles.

-
- ) : ( - - {(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).map(sprint => ( - -
-
-
- - {sprint.name} -
-
- {sprint.startDate} - {sprint.endDate} - {sprint.completedPoints}/{sprint.velocityPoints} pts -
-
- -
-
- ))} -
- )} -
+ ) : null} {section === "workspaces" ? ( @@ -1135,15 +1033,30 @@ export function App() { workspaces={companyWorkspaces} projects={companyProjects} companyId={currentCompany!.id} + selectedWorkspaceId={selectedWorkspace?.id ?? null} + onSelect={setSelectedWorkspaceId} onOpen={(path) => window.agentCompany.openPath(path).then(() => undefined)} onDeleted={refresh} />
} secondary={ - - - +
+ + project.id === selectedWorkspace.projectId) ?? null) : null} + onOpen={(path) => window.agentCompany.openPath(path).then(() => undefined)} + /> + + + + +
} /> ) : null} diff --git a/src/renderer/components/CompanyOnboarding.tsx b/src/renderer/components/CompanyOnboarding.tsx index 072b5f1f..788a383c 100644 --- a/src/renderer/components/CompanyOnboarding.tsx +++ b/src/renderer/components/CompanyOnboarding.tsx @@ -20,6 +20,7 @@ import { } from "@phosphor-icons/react"; import type { CompanyTemplate, ProfileSnapshot } from "@shared/types"; import { unwrap } from "../lib/desktop"; +import { getAgentInitials } from "../lib/formatters"; import { ActionButton, Input, TextArea } from "./ui"; type Step = "choose" | "template" | "company" | "goal" | "launch"; @@ -56,7 +57,6 @@ export function CompanyOnboarding({ const bestConnector = readyConnectors[0] ?? null; const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) ?? null; - // Load templates useEffect(() => { window.agentCompany.listCompanyTemplates().then((result) => { if (result.ok) setTemplates(result.data); @@ -75,7 +75,6 @@ export function CompanyOnboarding({ } } - // Template launch const launchFromTemplate = useCallback(async () => { if (loading || !selectedTemplate) return; if (!companyName.trim()) { @@ -98,7 +97,6 @@ export function CompanyOnboarding({ }), ); - // Create workspace for the company if path provided if (workspacePath) { unwrap( await window.agentCompany.saveWorkspace({ @@ -122,7 +120,6 @@ export function CompanyOnboarding({ } }, [loading, selectedTemplate, companyName, hasReadyConnector, workspacePath, bestConnector, onDone]); - // Custom company creation (existing flow) const createCompany = useCallback(async () => { if (loading) return; if (!companyName.trim()) { @@ -166,66 +163,77 @@ export function CompanyOnboarding({ } }, [loading, companyName, hasReadyConnector, autoApproveHires, workspacePath]); + const bootstrapInitialCompany = useCallback(async (companyId: string, seedGoal?: { title?: string; description?: string }) => { + if (seedGoal?.title?.trim()) { + unwrap( + await window.agentCompany.saveGoal({ + companyId, + title: seedGoal.title.trim(), + description: seedGoal.description?.trim() || `Achieve: ${seedGoal.title.trim()}`, + status: "active", + parentId: null, + ownerAgentId: null, + }), + ); + } + + if (bestConnector) { + const ceoId = unwrap( + await window.agentCompany.saveAgent({ + companyId, + name: "CEO", + role: "CEO", + title: "Chief Executive Officer", + reportsTo: null, + connectorId: bestConnector.id, + workspaceId: null, + model: null, + capabilities: "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.", + budgetMonthlyUsd: 250, + status: "idle", + }), + ); + unwrap(await window.agentCompany.setHeartbeat({ agentId: ceoId, companyId, enabled: true, intervalSec: 120 })); + setStep("launch"); + setTimeout(async () => { + try { + await window.agentCompany.triggerHeartbeat({ agentId: ceoId, companyId, trigger: "assignment" }); + } catch { + // Non-fatal. The CEO will wake on the next scheduled heartbeat. + } + }, 1500); + } + + await onDone(); + }, [bestConnector, onDone]); + const createGoalAndLaunch = useCallback(async () => { if (loading || !createdCompanyId) return; setLoading(true); setError(null); try { - if (goalTitle.trim()) { - unwrap( - await window.agentCompany.saveGoal({ - companyId: createdCompanyId, - title: goalTitle.trim(), - description: goalDescription.trim() || `Achieve: ${goalTitle.trim()}`, - status: "active", - parentId: null, - ownerAgentId: null, - }), - ); - } - if (bestConnector) { - const ceoId = unwrap( - await window.agentCompany.saveAgent({ - companyId: createdCompanyId, - name: "CEO", - role: "CEO", - title: "Chief Executive Officer", - reportsTo: null, - connectorId: bestConnector.id, - workspaceId: null, - model: null, - capabilities: "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.", - budgetMonthlyUsd: 250, - status: "idle", - }), - ); - unwrap(await window.agentCompany.setHeartbeat({ agentId: ceoId, companyId: createdCompanyId, enabled: true, intervalSec: 120 })); - setStep("launch"); - setTimeout(async () => { - try { - await window.agentCompany.triggerHeartbeat({ agentId: ceoId, companyId: createdCompanyId, trigger: "assignment" }); - } catch { /* Non-fatal */ } - }, 1500); - } - await onDone(); + await bootstrapInitialCompany(createdCompanyId, { + title: goalTitle, + description: goalDescription, + }); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : "Unable to set up the company."); setLoading(false); } - }, [loading, createdCompanyId, goalTitle, goalDescription, bestConnector, onDone]); + }, [loading, createdCompanyId, goalTitle, goalDescription, bootstrapInitialCompany]); const skipGoalAndFinish = useCallback(async () => { - if (loading) return; + if (loading || !createdCompanyId) return; setLoading(true); setError(null); try { - await onDone(); + await bootstrapInitialCompany(createdCompanyId); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : "Unable to continue."); } finally { setLoading(false); } - }, [loading, onDone]); + }, [loading, createdCompanyId, bootstrapInitialCompany]); const stepLabels = step === "choose" || step === "template" ? [ @@ -242,14 +250,13 @@ export function CompanyOnboarding({ const stepIndex = step === "choose" ? 0 : step === "template" ? 1 : step === "company" ? 0 : step === "goal" ? 1 : 2; return ( -
- {/* Left panel — branding */} -
+
+
AgentCompany Desktop

@@ -259,7 +266,7 @@ export function CompanyOnboarding({ Set up a fully autonomous AI-powered organization. Your agents will hire, delegate, execute, review, and coordinate — just like a real company.

- {/* Step indicators */} +
{stepLabels.map((s, i) => (
@@ -278,7 +285,7 @@ export function CompanyOnboarding({ ))}
- {/* Detected connectors */} +
Detected agents
@@ -313,10 +320,9 @@ export function CompanyOnboarding({
- {/* Right panel — form steps */} -
+
+
- {/* ── Step: Choose Mode ────────────────────────── */} {step === "choose" ? ( Choose your setup
- {/* Template cards */} +
Quick Start — Choose a template
@@ -378,7 +384,7 @@ export function CompanyOnboarding({
- {/* Custom option */} + ) : step === "template" && selectedTemplate ? ( - /* ── Step: Template Configuration ─────────────── */
- {/* Team preview */} +
Team that will be created
@@ -430,7 +435,7 @@ export function CompanyOnboarding({
- {agent.name.split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase()} + {getAgentInitials(agent.name)}
{agent.name} — {agent.title} @@ -449,7 +454,7 @@ export function CompanyOnboarding({
- {/* What happens */} +
What happens next
@@ -492,7 +497,6 @@ export function CompanyOnboarding({
) : step === "company" ? ( - /* ── Step: Custom Company ─────────────────────── */ ) : step === "goal" ? ( - /* ── Step: Goal ───────────────────────────────── */ ) : ( - /* ── Step: Launch Success ─────────────────────── */ )} +
); diff --git a/src/renderer/components/automation.tsx b/src/renderer/components/automation.tsx index bb221486..a047d0aa 100644 --- a/src/renderer/components/automation.tsx +++ b/src/renderer/components/automation.tsx @@ -17,7 +17,6 @@ import { X, } from "@phosphor-icons/react" import type { - AgentRecord, AutomationAction, AutomationRuleRecord, AutomationStatus, @@ -27,14 +26,12 @@ import type { WorkflowStatus, WorkflowStep, } from "@shared/types" +import { timeAgo } from "../lib/formatters" +import { DEPARTMENT_OPTIONS } from "@shared/constants" import { ActionButton, Select } from "./ui" -/* ------------------------------------------------------------------ */ -/* Constants & Config */ -/* ------------------------------------------------------------------ */ - -const ACCENT = "#d97745" -const LINE = "rgba(15,23,42,0.08)" +const ACCENT = "var(--accent)" +const LINE = "var(--line)" const triggerConfig: Record = { task_status_changed: { label: "Task Status Changed", description: "When a task transitions to a new status", icon: ArrowsClockwise }, @@ -70,21 +67,7 @@ const actionConfig: Record = [ - { value: "", label: "Any department" }, - { value: "executive", label: "Executive" }, - { value: "engineering", label: "Engineering" }, - { value: "product", label: "Product" }, - { value: "marketing", label: "Marketing" }, - { value: "sales", label: "Sales" }, - { value: "hr", label: "Human Resources" }, - { value: "finance", label: "Finance" }, - { value: "legal", label: "Legal" }, - { value: "operations", label: "Operations" }, - { value: "customer_support", label: "Customer Support" }, - { value: "research", label: "Research" }, - { value: "design", label: "Design" }, -] +const departmentOptions = DEPARTMENT_OPTIONS const statusColors: Record = { active: { bg: "var(--success-soft)", text: "var(--success)", dot: "var(--success)" }, @@ -109,25 +92,6 @@ const stepStatusConfig: Record
- {/* Trigger icon */}
- {/* Main info */}
- {/* Trigger label */} {triggerInfo?.label ?? rule.trigger} - {/* Action label */} {actionInfo?.label ?? rule.action} - {/* Department flow */} {rule.sourceDepartment || rule.targetDepartment ? ( <> | @@ -244,7 +195,6 @@ function RuleCard({ ) : null}
- {/* Execution stats */}
{rule.executionCount} execution{rule.executionCount !== 1 ? "s" : ""} @@ -254,7 +204,6 @@ function RuleCard({
- {/* Actions */}
+
+ ) : null}
- {/* Stats bar */} {rules.length > 0 ? (
@@ -850,7 +801,6 @@ function AutomationRulesPanel({
) : null} - {/* Tabs */}
- {/* Rules content */} {activeTab === "rules" ? ( <> - {/* Filter */} {rules.length > 0 ? (
{(["all", "active", "paused", "error"] as const).map((status) => ( @@ -900,7 +848,6 @@ function AutomationRulesPanel({
) : null} - {/* Rule list */} {filteredRules.length === 0 && rules.length === 0 ? (
@@ -941,7 +888,6 @@ function AutomationRulesPanel({ )} - {/* Create / Edit modal */} {showCreateModal ? ( - {/* Delete confirmation */} {deleteTarget ? ( - {/* Vertical line */}
- {/* Dot */}
- {/* Content */}
void onEdit: () => void + onRun: () => void }) { const [expanded, setExpanded] = useState(false) const colors = workflowStatusColors[workflow.status] @@ -1102,7 +1040,6 @@ function WorkflowCard({ style={{ borderColor: LINE }} >
- {/* Icon */}
- {/* Main info */}
- {/* Actions */}
+
- {/* Expanded: step timeline */} {expanded ? ( e.stopPropagation()} > - {/* Header */}
- {/* Body */}
{error ? ( @@ -1374,7 +1312,6 @@ function WorkflowFormModal({
) : null} - {/* Name */}
- {/* Description */}
- {/* Trigger + Status */}
- {/* Trigger Config */}
- {/* Steps */}
- {/* Footer */}
+
+ ) : null}
- {/* Workflow list */} {workflows.length === 0 ? (
@@ -1580,15 +1551,16 @@ function WorkflowsPanel({ setDeleteTarget(wf)} onEdit={() => setEditingWorkflow(wf)} + onRun={() => void handleRun(wf)} /> ))}
)} - {/* Create / Edit modal */} {showCreateModal ? ( - {/* Delete confirmation */} {deleteTarget ? ( void } @@ -1640,7 +1606,6 @@ interface AutomationPanelProps { export function AutomationPanel({ rules, workflows, - agents, companyId, onRefresh, }: AutomationPanelProps) { @@ -1648,7 +1613,6 @@ export function AutomationPanel({ return (
- {/* Top banner */}
- {/* Section tabs */}
- {/* Content */} = { - executive: "#8b5cf6", - engineering: "#3b82f6", - product: "#f59e0b", - marketing: "#ec4899", - sales: "#10b981", - hr: "#6366f1", - finance: "#14b8a6", - legal: "#a78bfa", - operations: "#f97316", - customer_support: "#06b6d4", - research: "#8b5cf6", - design: "#f43f5e", -} - function getDepartmentColor(department: DepartmentId | null | undefined): string { if (!department) return "var(--muted)" - return departmentColors[department] ?? "var(--muted)" + return DEPARTMENT_COLORS[department] ?? "var(--muted)" } -function getAgentInitials(name: string): string { - return name - .split(/\s+/) - .slice(0, 2) - .map((w) => w[0]) - .join("") - .toUpperCase() -} - -function timeAgo(dateStr: string): string { - const now = Date.now() - const then = new Date(dateStr).getTime() - const diff = Math.max(0, now - then) - const seconds = Math.floor(diff / 1000) - if (seconds < 60) return "just now" - const minutes = Math.floor(seconds / 60) - if (minutes < 60) return `${minutes}m ago` - const hours = Math.floor(minutes / 60) - if (hours < 24) return `${hours}h ago` - const days = Math.floor(hours / 24) - return `${days}d ago` -} - -function formatMessageTime(dateStr: string): string { - const d = new Date(dateStr) - const now = new Date() - const isToday = - d.getDate() === now.getDate() && - d.getMonth() === now.getMonth() && - d.getFullYear() === now.getFullYear() - - if (isToday) { - return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) - } - - const yesterday = new Date(now) - yesterday.setDate(yesterday.getDate() - 1) - const isYesterday = - d.getDate() === yesterday.getDate() && - d.getMonth() === yesterday.getMonth() && - d.getFullYear() === yesterday.getFullYear() - - if (isYesterday) { - return `Yesterday ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` - } - - return d.toLocaleDateString([], { month: "short", day: "numeric" }) + - " " + - d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) -} - -/* ------------------------------------------------------------------ */ -/* Channel Derivation */ -/* ------------------------------------------------------------------ */ - interface ChannelDescriptor { key: string + kind: "message" channel: MessageChannel label: string targetId: string | null @@ -136,6 +65,13 @@ interface ChannelDescriptor { agentId: string | null } +interface TaskDiscussionDescriptor { + key: string + kind: "task" + taskId: string + label: string +} + function deriveChannels( messages: AgentMessageRecord[], agents: AgentRecord[], @@ -143,6 +79,37 @@ function deriveChannels( const channelMap = new Map() const agentMap = new Map(agents.map((a) => [a.id, a])) + // Always create company-wide channel + channelMap.set("company-all", { + key: "company-all", + kind: "message", + channel: "company", + label: "#company-wide", + targetId: null, + agentId: null, + }) + + // Create permanent department channels for all departments that have agents + const activeDepts = new Set() + for (const agent of agents) { + if (agent.department && agent.status !== "terminated") { + activeDepts.add(agent.department) + } + } + for (const dept of activeDepts) { + const key = `department-${dept}` + const deptLabel = dept.replace(/_/g, "-") + channelMap.set(key, { + key, + kind: "message", + channel: "department", + label: `#${deptLabel}`, + targetId: dept, + agentId: null, + }) + } + + // Add channels derived from existing messages (DMs, projects, incidents) for (const msg of messages) { let key: string let label: string @@ -166,7 +133,7 @@ function deriveChannels( case "department": { const dept = msg.channelTargetId ?? "general" key = `department-${dept}` - label = `#${dept.replace("_", "-")}` + label = `#${dept.replace(/_/g, "-")}` break } case "company": { @@ -191,6 +158,7 @@ function deriveChannels( if (!channelMap.has(key)) { channelMap.set(key, { key, + kind: "message", channel: msg.channel, label, targetId: msg.channelTargetId, @@ -199,17 +167,6 @@ function deriveChannels( } } - // Ensure at least one company-wide channel exists - if (!channelMap.has("company-all")) { - channelMap.set("company-all", { - key: "company-all", - channel: "company", - label: "#company-wide", - targetId: null, - agentId: null, - }) - } - const order: MessageChannel[] = ["company", "department", "project", "incident", "direct"] return Array.from(channelMap.values()).sort((a, b) => { const ai = order.indexOf(a.channel) @@ -219,6 +176,25 @@ function deriveChannels( }) } +function deriveTaskDiscussionChannels( + comments: CommentRecord[], + tasks: TaskRecord[], +): TaskDiscussionDescriptor[] { + const taskMap = new Map(tasks.map((task) => [task.id, task])) + const discussedTaskIds = new Set(comments.map((comment) => comment.taskId)) + return Array.from(discussedTaskIds) + .map((taskId) => { + const task = taskMap.get(taskId) + return { + key: `task-${taskId}`, + kind: "task" as const, + taskId, + label: task?.title ?? `Task ${taskId.slice(0, 8)}`, + } + }) + .sort((a, b) => a.label.localeCompare(b.label)) +} + function getChannelMessages( messages: AgentMessageRecord[], channel: ChannelDescriptor, @@ -244,10 +220,6 @@ function getChannelMessages( }) } -/* ------------------------------------------------------------------ */ -/* Agent Avatar */ -/* ------------------------------------------------------------------ */ - function AgentAvatar({ agent, size = "md", @@ -269,10 +241,6 @@ function AgentAvatar({ ) } -/* ------------------------------------------------------------------ */ -/* Channel Sidebar Item */ -/* ------------------------------------------------------------------ */ - function ChannelItem({ descriptor, active, @@ -346,36 +314,23 @@ function ChannelItem({ ) } -/* ------------------------------------------------------------------ */ -/* Message Bubble */ -/* ------------------------------------------------------------------ */ - function MessageBubble({ message, agent, isThread, replyCount, onReply, - onMarkRead, }: { message: AgentMessageRecord agent: AgentRecord | undefined isThread: boolean replyCount: number onReply: () => void - onMarkRead: () => void }) { const isUnread = !message.readAt const isUrgent = message.priority === "urgent" const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted)" - useEffect(() => { - if (isUnread) { - const timer = setTimeout(onMarkRead, 1500) - return () => clearTimeout(timer) - } - }, [isUnread, onMarkRead]) - return ( - {/* Subject */} {message.subject ? (
{message.subject}
) : null} - {/* Body */}
{message.body}
@@ -510,9 +463,102 @@ function MessageBubble({ ) } -/* ------------------------------------------------------------------ */ -/* Attachment List */ -/* ------------------------------------------------------------------ */ +function TaskCommentBubble({ + comment, + agent, +}: { + comment: CommentRecord + agent: AgentRecord | undefined +}) { + const isBoard = !comment.authorAgentId + const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted)" + + return ( + + +
+
+ + {comment.authorName} + + {agent?.title ? {agent.title} : null} + {formatMessageTime(comment.createdAt)} +
+
+ {comment.body} +
+
+
+ ) +} + +function TaskDiscussionComposer({ + companyId, + taskId, + onSent, +}: { + companyId: string + taskId: string + onSent: () => void +}) { + const [body, setBody] = useState("") + const [sending, setSending] = useState(false) + + const handleSend = useCallback(async () => { + if (!body.trim()) return + setSending(true) + try { + await window.agentCompany.addComment({ + companyId, + taskId, + authorName: "Board", + body: body.trim(), + }) + setBody("") + onSent() + } finally { + setSending(false) + } + }, [body, companyId, taskId, onSent]) + + return ( +
+
+