This commit is contained in:
ZacharyZhang-NY committed 2026-04-16 14:48:51 -04:00
1 parent 9779c4d1ca
commit 1b578c111a
719 files changed
+191593 -98

No files matched your search

+154 -2
View File
@@ -1,7 +1,10 @@
import type { AppDatabase } from "./database";
import type { TaskRecord } from "@shared/types";
function safeGetAgent(db: AppDatabase, agentId: string) {
function safeGetAgent(db: AppDatabase, agentId: string | null | undefined) {
if (!agentId) {
return null;
}
try {
return db.getAgent(agentId);
} catch {
@@ -23,7 +26,7 @@ const MANAGEMENT_KEYWORDS = [
"lead",
];
function hasManagementAuthority(agent: { role: string; title: string; department: string | null }) {
export function hasManagementAuthority(agent: { role: string; title: string; department: string | null }) {
if (agent.department === "hr") {
return true;
}
@@ -89,6 +92,155 @@ export function canRequestHire(db: AppDatabase, companyId: string, agentId: stri
return hasManagementAuthority(agent);
}
function getDepartmentLeadCandidateId(
db: AppDatabase,
companyId: string,
department: string,
excludeAgentId: string | null,
): string | null {
const candidates = db.queryAll<{
id: string;
role: string;
title: string;
department: string | null;
createdAt: string;
}>(
`select id, role, title, department, created_at as createdAt
from agents
where company_id = ?
and department = ?
and status not in ('terminated', 'pending_approval')`,
companyId,
department,
);
const ranked = candidates
.filter((candidate) => candidate.id !== excludeAgentId)
.map((candidate) => ({
id: candidate.id,
hasReports: db.getDirectReports(candidate.id, companyId).length > 0,
leadershipScore: hasManagementAuthority(candidate) ? 1 : 0,
chainDepth: db.getChainOfCommand(candidate.id).length,
createdAt: candidate.createdAt,
}))
.filter((candidate) => candidate.hasReports || candidate.leadershipScore > 0)
.sort((left, right) => {
if (left.hasReports !== right.hasReports) return left.hasReports ? -1 : 1;
if (left.leadershipScore !== right.leadershipScore) return right.leadershipScore - left.leadershipScore;
if (left.chainDepth !== right.chainDepth) return left.chainDepth - right.chainDepth;
return left.createdAt.localeCompare(right.createdAt);
});
return ranked[0]?.id ?? null;
}
export function resolveHireReportsTo(
db: AppDatabase,
companyId: string,
requestedByAgentId: string | null | undefined,
explicitReportsTo: string | null | undefined,
department: string | null | undefined,
): string | null {
if (explicitReportsTo !== undefined) {
return explicitReportsTo;
}
const requester = requestedByAgentId ? safeGetAgent(db, requestedByAgentId) : null;
if (requester && requester.companyId !== companyId) {
return null;
}
if (!department) {
return requester?.id ?? db.getCeoForCompany(companyId)?.id ?? null;
}
if (requester && (requester.department === department || !requester.reportsTo)) {
return requester.id;
}
const departmentLeadId = getDepartmentLeadCandidateId(db, companyId, department, requester?.id ?? null);
if (departmentLeadId) {
return departmentLeadId;
}
if (requester) {
return requester.id;
}
return db.getCeoForCompany(companyId)?.id ?? null;
}
function normalizeComparableText(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[\s_\-()[\]{}]+/g, " ")
.replace(/\s+/g, " ");
}
function isAllowedRoleAcronym(value: string): boolean {
return /^[A-Z]{2,5}$/.test(value.trim());
}
export function getHireNameValidationError(input: {
name: string;
role: string;
title: string;
}): string | null {
const normalizedName = normalizeComparableText(input.name);
if (!normalizedName) {
return "Hire requests require a real display name.";
}
const normalizedRole = normalizeComparableText(input.role);
const normalizedTitle = normalizeComparableText(input.title);
if (normalizedName === normalizedRole && !isAllowedRoleAcronym(input.name)) {
return "Hire name must be a real display name, not just the role.";
}
if (
normalizedTitle.length > 0 &&
normalizedName === normalizedTitle &&
!isAllowedRoleAcronym(input.name)
) {
return "Hire name must be a real display name, not just the title.";
}
return null;
}
export function getHireRoutingValidationError(
db: AppDatabase,
companyId: string,
requestedByAgentId: string | null | undefined,
reportsTo: string | null | undefined,
department: string | null | undefined,
): string | null {
const requester = requestedByAgentId ? safeGetAgent(db, requestedByAgentId) : null;
const manager = reportsTo ? safeGetAgent(db, reportsTo) : null;
const requestedDepartment = department ?? null;
if (requester && requester.companyId !== companyId) {
return "Requesting agent does not belong to the current company.";
}
if (!requestedDepartment) {
return null;
}
if (reportsTo != null && !manager) {
return "Selected manager not found.";
}
if (manager && manager.companyId !== companyId) {
return "Selected manager does not belong to the current company.";
}
if (
manager &&
manager.reportsTo != null &&
manager.department !== requestedDepartment
) {
return "Selected manager must belong to the same department as the new hire, unless they are a top-level executive.";
}
return null;
}
export function canAssignTask(db: AppDatabase, companyId: string, actorAgentId: string, targetAgentId: string | null): boolean {
if (!targetAgentId || targetAgentId === actorAgentId) {
return true;
+50 -14
View File
@@ -1,7 +1,13 @@
import log from "electron-log/main.js";
import { hireRequestSchema } from "@shared/contracts";
import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy";
import { canManageOrganization, canRequestHire } from "../agent-permissions";
import {
canManageOrganization,
canRequestHire,
getHireNameValidationError,
getHireRoutingValidationError,
resolveHireReportsTo,
} from "../agent-permissions";
import { resolveApprovalDecision, shouldAutoApproveApproval } from "../approval-policy";
import { requireParam, type AgentApiServer } from "./server-core";
@@ -236,32 +242,59 @@ export function registerAgentRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Current agent is not allowed to request hires" });
return;
}
const reportsToProvided =
typeof body === "object" &&
body !== null &&
Object.prototype.hasOwnProperty.call(body, "reportsTo");
const parsed = hireRequestSchema.safeParse({
companyId,
requestedByAgentId: token.agentId,
...(typeof body === "object" && body ? body : {}),
reportsTo:
typeof body === "object" && body && "reportsTo" in body
? (body as { reportsTo?: string | null }).reportsTo
: token.agentId,
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid hire payload" });
return;
}
const input = parsed.data;
const resolvedReportsTo = resolveHireReportsTo(
db,
companyId,
token.agentId,
reportsToProvided ? input.reportsTo : undefined,
input.department ?? null,
);
const nameError = getHireNameValidationError({
name: input.name,
role: input.role,
title: input.title,
});
if (nameError) {
server.json(res, 422, { error: nameError });
return;
}
const routingError = getHireRoutingValidationError(
db,
companyId,
token.agentId,
resolvedReportsTo,
input.department ?? null,
);
if (routingError) {
server.json(res, 422, { error: routingError });
return;
}
if (server.companyRefError(res, companyId, [
{ id: input.requestedByAgentId, table: "agents", label: "requesting agent" },
{ id: input.reportsTo, table: "agents", label: "manager" },
{ id: resolvedReportsTo, table: "agents", label: "manager" },
{ id: input.workspaceId, table: "workspaces", label: "workspace" },
])) {
return;
}
if (
input.reportsTo &&
input.reportsTo !== token.agentId &&
resolvedReportsTo &&
resolvedReportsTo !== token.agentId &&
!canManageOrganization(db, companyId, token.agentId) &&
!db.getChainOfCommand(input.reportsTo).some((agent) => agent.id === token.agentId)
!db.getChainOfCommand(resolvedReportsTo).some((agent) => agent.id === token.agentId)
) {
server.json(res, 403, { error: "Requested hire must report to you or a manager in your reporting chain." });
return;
@@ -310,12 +343,12 @@ export function registerAgentRoutes(server: AgentApiServer) {
} catch { /* invalid payloadJson, skip */ }
}
const managerLabel = input.reportsTo
const managerLabel = resolvedReportsTo
? (() => {
try {
return db.getAgent(input.reportsTo).name;
return db.getAgent(resolvedReportsTo).name;
} catch {
return input.reportsTo;
return resolvedReportsTo;
}
})()
: "the company board";
@@ -329,7 +362,7 @@ export function registerAgentRoutes(server: AgentApiServer) {
role: input.role,
title: input.title,
department: input.department ?? null,
reportsTo: input.reportsTo ?? token.agentId,
reportsTo: resolvedReportsTo,
connectorId: input.connectorId,
workspaceId: input.workspaceId ?? null,
model: input.model ?? null,
@@ -339,6 +372,9 @@ export function registerAgentRoutes(server: AgentApiServer) {
payloadSummary: `Hire ${input.name} as ${input.role}`,
impactSummary: input.capabilities || `${input.name} will report to ${managerLabel}.`,
payloadJson: JSON.stringify({
name: input.name,
role: input.role,
title: input.title,
requestedByAgentId: input.requestedByAgentId ?? token.agentId,
requestedConfigurationSnapshot: {
connectorId: input.connectorId,
@@ -347,7 +383,7 @@ export function registerAgentRoutes(server: AgentApiServer) {
metadataJson: input.metadataJson ?? "{}",
capabilities: input.capabilities,
budgetMonthlyUsd: input.budgetMonthlyUsd,
reportsTo: input.reportsTo ?? token.agentId,
reportsTo: resolvedReportsTo,
department: input.department ?? null,
title: input.title,
},
+31 -3
View File
@@ -50,8 +50,30 @@ export function buildBudgetMethods(
notifyEnabled?: boolean;
isActive?: boolean;
}): string {
const id = input.id || randomUUID();
const ts = nowIso();
const metric = input.metric ?? "billed_cents";
const windowKind = input.windowKind ?? "calendar_month_utc";
const scopedExisting = input.id
? []
: readAll<{ id: string; createdAt: string }>(
`SELECT id, created_at as createdAt
FROM budget_policies
WHERE company_id = ? AND scope_type = ? AND scope_id = ? AND metric = ? AND window_kind = ?
ORDER BY created_at ASC`,
input.companyId,
input.scopeType,
input.scopeId,
metric,
windowKind,
);
const existing = input.id
? getDb()
.prepare("SELECT id, created_at as createdAt FROM budget_policies WHERE id = ?")
.get(input.id) as { id: string; createdAt: string } | undefined
: scopedExisting[0];
const staleIds = input.id ? [] : scopedExisting.slice(1).map((policy) => policy.id);
const id = existing?.id ?? input.id ?? randomUUID();
const createdAt = existing?.createdAt ?? ts;
getDb()
.prepare(
`INSERT OR REPLACE INTO budget_policies
@@ -61,12 +83,18 @@ export function buildBudgetMethods(
)
.run(
id, input.companyId, input.scopeType, input.scopeId,
input.metric ?? "billed_cents", input.windowKind ?? "calendar_month_utc",
metric, windowKind,
input.amount, input.warnPercent ?? 80,
(input.hardStopEnabled ?? true) ? 1 : 0,
(input.notifyEnabled ?? true) ? 1 : 0,
(input.isActive ?? true) ? 1 : 0, ts, ts,
(input.isActive ?? true) ? 1 : 0, createdAt, ts,
);
if (staleIds.length > 0) {
const placeholders = staleIds.map(() => "?").join(", ");
getDb()
.prepare(`UPDATE budget_policies SET is_active = 0, updated_at = ? WHERE id IN (${placeholders})`)
.run(ts, ...staleIds);
}
return id;
},
+1
View File
@@ -33,6 +33,7 @@ export function buildEntityCrudMethods(
)
.get(id) as AgentRecord | undefined;
if (!row) throw new Error(`Agent not found: ${id}`);
row.heartbeatEnabled = Boolean(row.heartbeatEnabled);
return row;
},
+160
View File
@@ -0,0 +1,160 @@
import { resolveApprovalDecision } from "./approval-policy";
import type { AppDatabase } from "./database";
type ReconcileExecutionModeChangeInput = {
db: AppDatabase;
companyId: string;
previousExecutionMode: string;
nextExecutionMode: string;
previousReviewDeliverables: boolean;
nextReviewDeliverables: boolean;
handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
};
const COFOUNDER_TRANSITION_APPROVAL_TYPES = [
"deliverable_review",
"document_review",
"plan_review",
"scope_review",
"draft_review",
"strategy_review",
"exception_review",
"approve_ceo_strategy",
] as const;
const REVIEW_WAIT_STATE_TYPES = new Set(["user_reply", "user_review", "user_approval"]);
function collectImpactedAgentIdsFromWaitStates(db: AppDatabase, companyId: string): Set<string> {
const agentIds = new Set<string>();
const waitStates = db.listThreadWaitStates(companyId);
for (const waitState of waitStates) {
const thread = db.getThread(waitState.threadId, companyId);
if (!thread || thread.relatedEntityType !== "task" || !thread.relatedEntityId) continue;
try {
const task = db.getTask(thread.relatedEntityId);
if (task.assigneeAgentId) {
agentIds.add(task.assigneeAgentId);
}
} catch {
continue;
}
}
return agentIds;
}
function collectImpactedAgentIdsFromReviewState(db: AppDatabase, companyId: string): Set<string> {
const agentIds = new Set<string>();
const flaggedTasks = db.queryAll<{ assigneeAgentId: string | null }>(
"select assignee_agent_id as assigneeAgentId from tasks where company_id = ? and requires_user_review = 1",
companyId,
);
for (const task of flaggedTasks) {
if (task.assigneeAgentId) {
agentIds.add(task.assigneeAgentId);
}
}
const pendingApprovals = db.queryAll<{ requestedByAgentId: string | null; relatedTaskId: string | null }>(
`select requested_by_agent_id as requestedByAgentId, related_task_id as relatedTaskId
from approvals
where company_id = ?
and state = 'pending'
and type = 'deliverable_review'`,
companyId,
);
for (const approval of pendingApprovals) {
if (approval.requestedByAgentId) {
agentIds.add(approval.requestedByAgentId);
}
if (!approval.relatedTaskId) continue;
try {
const task = db.getTask(approval.relatedTaskId);
if (task.assigneeAgentId) {
agentIds.add(task.assigneeAgentId);
}
} catch {
continue;
}
}
return agentIds;
}
function resumeAgents(db: AppDatabase, companyId: string, agentIds: Iterable<string>): void {
for (const agentId of agentIds) {
try {
const agent = db.getAgent(agentId);
if (agent.companyId !== companyId) continue;
if (["terminated", "pending_approval"].includes(agent.status)) continue;
if (!agent.heartbeatEnabled) {
db.setHeartbeat(agent.id, true, agent.heartbeatIntervalSec || 120);
}
} catch {
continue;
}
}
}
export function reconcileExecutionModeChange({
db,
companyId,
previousExecutionMode,
nextExecutionMode,
previousReviewDeliverables,
nextReviewDeliverables,
handleApprovalResolved,
}: ReconcileExecutionModeChangeInput): void {
const leavingCofounderMode =
previousExecutionMode === "ai_cofounder" && nextExecutionMode === "ai_ceo";
const disablingDeliverableReviews =
previousReviewDeliverables && !nextReviewDeliverables;
if (!leavingCofounderMode && !disablingDeliverableReviews) {
return;
}
const impactedAgents = new Set<string>([
...collectImpactedAgentIdsFromWaitStates(db, companyId),
...collectImpactedAgentIdsFromReviewState(db, companyId),
]);
if (leavingCofounderMode) {
const pendingApprovals = db.queryAll<{ id: string; type: string }>(
`select id, type
from approvals
where company_id = ?
and state = 'pending'
and type in (${COFOUNDER_TRANSITION_APPROVAL_TYPES.map(() => "?").join(", ")})`,
companyId,
...COFOUNDER_TRANSITION_APPROVAL_TYPES,
);
for (const approval of pendingApprovals) {
resolveApprovalDecision({
db,
handleApprovalResolved,
companyId,
approvalId: approval.id,
state: "approved",
decisionNote: "Auto-approved because the company switched to AI CEO mode.",
detail: `Mode transition auto-approved ${approval.type}.`,
actor: "system",
});
}
const waitStates = db.listThreadWaitStates(companyId);
for (const waitState of waitStates) {
if (!REVIEW_WAIT_STATE_TYPES.has(waitState.waitingFor)) continue;
db.resolveThreadWaitState(waitState.id, companyId);
}
}
if (disablingDeliverableReviews || leavingCofounderMode) {
const tasks = db.queryAll<{ id: string }>(
"select id from tasks where company_id = ? and requires_user_review = 1",
companyId,
);
for (const task of tasks) {
db.setTaskRequiresUserReview(task.id, false);
}
}
resumeAgents(db, companyId, impactedAgents);
}
+16 -1
View File
@@ -8,6 +8,7 @@ import {
secretInputSchema,
} from "@shared/contracts";
import { validateConnectorConfigurationModel, validateConnectorModelSelection } from "./connector-model-policy";
import { reconcileExecutionModeChange } from "./execution-mode-reconciler";
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
export function registerCoreAdminHandlers({
@@ -15,16 +16,30 @@ export function registerCoreAdminHandlers({
db,
ensureCompanyExists,
publishDomainChanged,
handleApprovalResolved,
checkConnector,
openConnectorAuthTerminal,
openGenericTerminal,
}: Pick<
RegisterCoreHandlersDependencies,
"registerHandle" | "db" | "ensureCompanyExists" | "publishDomainChanged" | "checkConnector" | "openConnectorAuthTerminal" | "openGenericTerminal"
"registerHandle" | "db" | "ensureCompanyExists" | "publishDomainChanged" | "handleApprovalResolved" | "checkConnector" | "openConnectorAuthTerminal" | "openGenericTerminal"
>) {
registerHandle(IPC_CHANNELS.saveCompany, async (_event, payload) => {
const parsed = companyInputSchema.parse(payload);
const previousMode = parsed.id ? db.getCompanyExecutionMode(parsed.id) : parsed.executionMode;
const previousApprovalSettings = parsed.id
? db.getCompanyApprovalSettings(parsed.id)
: { autoApproveHires: parsed.autoApproveHires, reviewDeliverables: parsed.reviewDeliverables };
const id = db.saveCompany(parsed);
reconcileExecutionModeChange({
db,
companyId: id,
previousExecutionMode: previousMode,
nextExecutionMode: parsed.executionMode,
previousReviewDeliverables: previousApprovalSettings?.reviewDeliverables ?? false,
nextReviewDeliverables: parsed.reviewDeliverables,
handleApprovalResolved,
});
db.addActivity({
companyId: id,
actor: "board",
+35 -6
View File
@@ -7,6 +7,7 @@ import {
} from "@shared/contracts";
import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy";
import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy";
import { getHireNameValidationError, getHireRoutingValidationError, resolveHireReportsTo } from "./agent-permissions";
import { validateConnectorModelSelection } from "./connector-model-policy";
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
@@ -146,9 +147,16 @@ export function registerCoreAgentGraphHandlers({
const parsed = hireRequestSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError;
const resolvedReportsTo = resolveHireReportsTo(
db,
parsed.companyId,
parsed.requestedByAgentId ?? null,
parsed.reportsTo,
parsed.department ?? null,
);
const invalidReference = findInvalidReference(parsed.companyId, [
{ id: parsed.requestedByAgentId, table: "agents", label: "requesting agent" },
{ id: parsed.reportsTo, table: "agents", label: "manager" },
{ id: resolvedReportsTo, table: "agents", label: "manager" },
{ id: parsed.workspaceId, table: "workspaces", label: "workspace" },
]);
if (invalidReference) {
@@ -168,16 +176,34 @@ export function registerCoreAgentGraphHandlers({
if (connectorIssue) {
return fail("CONNECTOR_NOT_READY", connectorIssue);
}
const nameError = getHireNameValidationError({
name: parsed.name,
role: parsed.role,
title: parsed.title,
});
if (nameError) {
return fail("INVALID_HIRE_NAME", nameError);
}
const routingError = getHireRoutingValidationError(
db,
parsed.companyId,
parsed.requestedByAgentId ?? null,
parsed.reportsTo,
parsed.department ?? null,
);
if (routingError) {
return fail("INVALID_HIRE_ROUTING", routingError);
}
const modelSelection = validateConnectorModelSelection(db, parsed.connectorId, parsed.model ?? null);
if (!modelSelection.ok) {
return fail("CONNECTOR_MODEL_NOT_ALLOWED", modelSelection.error);
}
const managerLabel = parsed.reportsTo
const managerLabel = resolvedReportsTo
? (() => {
try {
return db.getAgent(parsed.reportsTo).name;
return db.getAgent(resolvedReportsTo).name;
} catch {
return parsed.reportsTo;
return resolvedReportsTo;
}
})()
: "the company board";
@@ -192,7 +218,7 @@ export function registerCoreAgentGraphHandlers({
role: parsed.role,
title: parsed.title,
department: parsed.department ?? null,
reportsTo: parsed.reportsTo ?? null,
reportsTo: resolvedReportsTo,
connectorId: parsed.connectorId,
workspaceId: parsed.workspaceId ?? null,
model: parsed.model ?? null,
@@ -202,6 +228,9 @@ export function registerCoreAgentGraphHandlers({
payloadSummary,
impactSummary,
payloadJson: JSON.stringify({
name: parsed.name,
role: parsed.role,
title: parsed.title,
requestedByAgentId: parsed.requestedByAgentId ?? null,
requestedConfigurationSnapshot: {
connectorId: parsed.connectorId,
@@ -210,7 +239,7 @@ export function registerCoreAgentGraphHandlers({
metadataJson: parsed.metadataJson ?? "{}",
capabilities: parsed.capabilities,
budgetMonthlyUsd: parsed.budgetMonthlyUsd,
reportsTo: parsed.reportsTo ?? null,
reportsTo: resolvedReportsTo,
department: parsed.department ?? null,
title: parsed.title,
},
+16 -2
View File
@@ -80,14 +80,28 @@ export function createHeartbeat(
return null;
}
const companyMode = db.getCompanyExecutionMode(companyId);
const approvalSettings = db.getCompanyApprovalSettings(companyId);
const shouldEnforceHumanReview =
companyMode === "ai_cofounder" || approvalSettings?.reviewDeliverables === true;
// Block heartbeat when agent has tasks awaiting user deliverable review
if (!isMessageWake && (db.hasAgentPendingDeliverableReview(agentId, companyId) || db.hasAgentTaskAwaitingReview(agentId, companyId))) {
if (
shouldEnforceHumanReview &&
!isMessageWake &&
(db.hasAgentPendingDeliverableReview(agentId, companyId) || db.hasAgentTaskAwaitingReview(agentId, companyId))
) {
logger.debug(`[heartbeat] Skipping ${agent.name} — awaiting user deliverable review.`);
return null;
}
// Block CEO heartbeat when company has any pending deliverable reviews
if (!isMessageWake && !agent.reportsTo && db.hasPendingDeliverableReviews(companyId)) {
if (
shouldEnforceHumanReview &&
!isMessageWake &&
!agent.reportsTo &&
db.hasPendingDeliverableReviews(companyId)
) {
logger.debug(`[heartbeat] Skipping CEO ${agent.name} — company has pending deliverable reviews.`);
return null;
}
+1 -1
View File
@@ -313,7 +313,7 @@ Body: {
### Propose a Hire
POST /api/companies/${ctx.agent.companyId}/agents
Body: {
"name": "New hire name",
"name": "New hire display name (must not repeat role/title)",
"role": "role_title",
"title": "Descriptive title",
"department": "engineering|product|marketing|sales|hr|finance|legal|operations|customer_support|research|design|executive",
@@ -43,10 +43,10 @@ Start with C-suite and VPs who report to you (3-5 max), then let them hire their
## Step 3: Submit Hire Requests
For EACH role, use POST /api/companies/{companyId}/agents with:
- A clear, professional name
- A clear, professional name that is not just the role or title repeated back
- Specific role and title
- Detailed capabilities describing what they should own
- reportsTo: YOUR agent ID (they report to you)
- reportsTo: an explicit manager agent ID (do not omit this field)
- connectorId: Use the connector recommended in the "Available Connectors" section above. If no section is shown, use claude_local.
- budgetMonthlyUsd: Appropriate budget (recommend $50-250/agent)
- model: Best model for the role's complexity
@@ -159,7 +159,7 @@ When workload exceeds capacity or new capabilities are needed:
- 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
- Each hire needs: a distinct agent name, clear role/title, specific capabilities, budget justification, department assignment, and the correct manager (reportsTo)
- After hire approval, create onboarding tasks for the new agent
## Priority 7: Strategic Communication & Reporting
+1 -1
View File
@@ -41,7 +41,7 @@ When reviewing work from reports:
## SOP-005: Hiring Protocol
When the team needs new members:
1. Identify the capability gap (what work can't be done with current team?)
2. Define the role clearly: name, title, capabilities, which manager they report to
2. Define the role clearly: a distinct agent name, title, capabilities, and which manager they report to
3. Choose the best connector/model for the role
4. Submit via POST /api/companies/{companyId}/agents
5. The hire enters pending_approval — the Board must approve
@@ -215,7 +215,7 @@ export function ApprovalInspector({ approval, agents, tasks, documents, companyI
</div>
{approval.type === "hire_agent" ? (() => { const payload = parseApprovalPayloadJson(approval); const payloadAgentId = typeof payload?.agentId === "string" ? payload.agentId : null; const reqSnap = payload?.requestedConfigurationSnapshot && typeof payload.requestedConfigurationSnapshot === "object" && !Array.isArray(payload.requestedConfigurationSnapshot) ? payload.requestedConfigurationSnapshot as Record<string, unknown> : null; const proposedAgent = (approval.relatedAgentId ? (agents.find((a) => a.id === approval.relatedAgentId) ?? null) : null) ?? (payloadAgentId ? (agents.find((a) => a.id === payloadAgentId) ?? null) : null); const mgrId = proposedAgent?.reportsTo ?? (typeof reqSnap?.reportsTo === "string" ? reqSnap.reportsTo : null); const mgr = mgrId ? (agents.find((a) => a.id === mgrId) ?? null) : null; if (!proposedAgent && !reqSnap) return null; return (
<div className="rounded-lg border border-[color:var(--primary)] border-opacity-30 bg-accent px-4 py-3"><div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">Proposed Hire</div><div className="grid gap-1.5 text-[12px]">
<div><span className="text-muted-foreground">Name:</span> <span className="font-medium text-foreground">{proposedAgent?.name ?? approval.payloadSummary.replace(/^Hire\s+/i, "").split(/\s+as\s+/i)[0] ?? "Pending hire"}</span></div>
<div><span className="text-muted-foreground">Name:</span> <span className="font-medium text-foreground">{proposedAgent?.name ?? (typeof reqSnap?.name === "string" ? reqSnap.name : "Unspecified")}</span></div>
<div><span className="text-muted-foreground">Role:</span> <span className="text-foreground">{proposedAgent?.role ?? (typeof reqSnap?.role === "string" ? reqSnap.role : "Unspecified")}</span></div>
{(proposedAgent?.title || typeof reqSnap?.title === "string") ? <div><span className="text-muted-foreground">Title:</span> <span className="text-foreground">{proposedAgent?.title || reqSnap?.title as string}</span></div> : null}
{(proposedAgent?.capabilities || typeof reqSnap?.capabilities === "string") ? <div><span className="text-muted-foreground">Capabilities:</span> <span className="text-foreground">{proposedAgent?.capabilities || reqSnap?.capabilities as string}</span></div> : null}
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import type { BudgetPolicyRecord } from "@shared/types";
import { ActionButton, Input, Modal, Select } from "../ui";
import { buildBudgetPolicyInput, getBudgetPolicyAmountDollars } from "../../lib/budget-policy-input";
interface PolicyFormModalProps {
open: boolean;
@@ -25,7 +26,7 @@ export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPoli
if (editingPolicy) {
setScopeType(editingPolicy.scopeType);
setScopeId(editingPolicy.scopeId);
setAmountDollars((editingPolicy.amount / 100).toFixed(2));
setAmountDollars(getBudgetPolicyAmountDollars(editingPolicy));
setWarnPercent(String(editingPolicy.warnPercent));
setHardStopEnabled(editingPolicy.hardStopEnabled);
setNotifyEnabled(editingPolicy.notifyEnabled);
@@ -52,24 +53,19 @@ export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPoli
async function handleSave() {
setSaving(true);
try {
const amountCents = Math.round(parseFloat(amountDollars) * 100);
if (Number.isNaN(amountCents) || amountCents <= 0) return;
const wp = parseInt(warnPercent, 10);
if (Number.isNaN(wp) || wp < 1 || wp > 100) return;
const result = await window.agentCompany.saveBudgetPolicy({
const payload = buildBudgetPolicyInput({
policyId: editingPolicy?.id ?? null,
companyId,
scopeType,
scopeId,
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: amountCents,
warnPercent: wp,
amountDollars,
warnPercent,
hardStopEnabled,
notifyEnabled,
isActive: true,
});
if (!payload) return;
const result = await window.agentCompany.saveBudgetPolicy(payload);
if (result.ok) {
onSaved();
onClose();
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Robot } from "@phosphor-icons/react";
import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
import { DEPARTMENT_OPTIONS } from "@shared/constants";
import type { AgentRecord, ConnectorRecord, DepartmentId, WorkspaceRecord } from "@shared/types";
import { isConnectorExecutionReady } from "@shared/connector-policy";
import { unwrap } from "../../lib/desktop";
import { ActionButton, Input, Select, TextArea } from "../ui";
@@ -27,6 +28,7 @@ export function AgentCreator({
const [name, setName] = useState("");
const [role, setRole] = useState("");
const [title, setTitle] = useState("");
const [department, setDepartment] = useState<DepartmentId | "">("");
const [connectorId, setConnectorId] = useState(executionReadyConnectors[0]?.id ?? "");
const [model, setModel] = useState("");
const [availableModels, setAvailableModels] = useState<Array<{ id: string; label: string }>>([]);
@@ -69,6 +71,7 @@ export function AgentCreator({
const t = useT();
const [showAdvanced, setShowAdvanced] = useState(false);
const selectedDepartment: DepartmentId | null = department === "" ? null : department;
return (
<div className="grid min-w-0 gap-4">
@@ -114,6 +117,15 @@ export function AgentCreator({
<AnimatePresence>
{showAdvanced ? (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }} className="grid gap-4 overflow-hidden">
<Select
label={t("comm.department")}
value={department}
onChange={(value) => setDepartment(value as DepartmentId | "")}
options={DEPARTMENT_OPTIONS.map((option) => ({
value: option.value,
label: option.value === "" ? t("comm.selectDepartment") : option.label,
}))}
/>
<Select
label={t("agent.workspace")}
value={workspaceId}
@@ -162,21 +174,22 @@ export function AgentCreator({
unwrap(await window.agentCompany.requestHire({
companyId,
requestedByAgentId: null,
name,
role,
title,
department: null,
name: name.trim(),
role: role.trim(),
title: title.trim(),
department: selectedDepartment,
connectorId: connectorId as ConnectorRecord["id"],
workspaceId: workspaceId || null,
reportsTo: reportsTo || null,
model: model || null,
metadataJson: JSON.stringify(meta),
capabilities,
capabilities: capabilities.trim(),
budgetMonthlyUsd: Number(budget || 0),
}));
setName("");
setRole("");
setTitle("");
setDepartment("");
setCapabilities("");
setReportsTo("");
setRequestError(null);
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { Pencil, Trash } from "@phosphor-icons/react";
import type { AgentRecord, BudgetPolicyRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
import { DEPARTMENT_OPTIONS } from "@shared/constants";
import type { AgentRecord, BudgetPolicyRecord, ConnectorRecord, DepartmentId, WorkspaceRecord } from "@shared/types";
import { getConnectorExecutionReadinessIssue, isConnectorExecutionReady } from "@shared/connector-policy";
import { unwrap } from "../../lib/desktop";
import { buildBudgetPolicyInput, getBudgetPolicyAmountDollars } from "../../lib/budget-policy-input";
import { useAppStore } from "../../state/store";
import { useT } from "../../i18n";
import { ActionButton, Input, Select, TextArea } from "../ui";
@@ -30,6 +32,7 @@ export function AgentEditor({
const [name, setName] = useState(agent.name);
const [role, setRole] = useState(agent.role);
const [title, setTitle] = useState(agent.title);
const [department, setDepartment] = useState<DepartmentId | "">(agent.department ?? "");
const [status, setStatus] = useState<AgentRecord["status"]>(agent.status);
const [connectorId, setConnectorId] = useState<ConnectorRecord["id"]>(agent.connectorId);
const [model, setModel] = useState(agent.model ?? "");
@@ -108,18 +111,22 @@ export function AgentEditor({
void (async () => {
const result = await window.agentCompany.listBudgetPolicies(companyId);
if (result.ok) {
const policy = result.data.find(
(p) => p.scopeType === "agent" && p.scopeId === agent.id
);
const policy = result.data
.filter((p) => p.scopeType === "agent" && p.scopeId === agent.id)
.sort((left, right) => {
if (left.isActive !== right.isActive) return left.isActive ? -1 : 1;
return right.updatedAt.localeCompare(left.updatedAt);
})[0];
if (policy) {
setAgentPolicy(policy);
setBudget(String((policy.amount / 100).toFixed(2)));
setBudget(getBudgetPolicyAmountDollars(policy));
}
}
})();
}, [companyId, agent.id]);
const managerCandidates = agents.filter((a) => a.id !== agent.id);
const selectedDepartment: DepartmentId | null = department === "" ? null : department;
async function persistAgentDraft() {
const meta = { ...existingMeta };
@@ -130,9 +137,10 @@ export function AgentEditor({
unwrap(await window.agentCompany.saveAgent({
id: agent.id,
companyId,
name,
role,
title,
name: name.trim(),
role: role.trim(),
title: title.trim(),
department: selectedDepartment,
status,
connectorId,
workspaceId: workspaceId || null,
@@ -140,22 +148,24 @@ export function AgentEditor({
model: model || null,
metadataJson: JSON.stringify(meta),
budgetMonthlyUsd: budgetDollars,
capabilities,
capabilities: capabilities.trim(),
}));
if (budgetDollars > 0 && agent.id) {
await window.agentCompany.saveBudgetPolicy({
const payload = buildBudgetPolicyInput({
policyId: agentPolicy?.id ?? null,
companyId,
scopeType: "agent",
scopeId: agent.id,
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: Math.round(budgetDollars * 100),
warnPercent: agentPolicy?.warnPercent ?? 80,
amountDollars: String(budgetDollars),
warnPercent: String(agentPolicy?.warnPercent ?? 80),
hardStopEnabled: agentPolicy?.hardStopEnabled ?? true,
notifyEnabled: agentPolicy?.notifyEnabled ?? true,
isActive: true,
});
if (!payload) {
throw new Error("Agent budget policy is invalid.");
}
unwrap(await window.agentCompany.saveBudgetPolicy(payload));
}
}
@@ -175,6 +185,16 @@ export function AgentEditor({
<Input label={t("agent.name")} value={name} onChange={setName} placeholder={t("agent.name")} readOnly={pendingApproval} disabled={pendingApproval} />
<Input label={t("agent.role")} value={role} onChange={setRole} placeholder={t("agent.role")} readOnly={pendingApproval} disabled={pendingApproval} />
<Input label={t("agent.title")} value={title} onChange={setTitle} placeholder={t("agent.title")} readOnly={pendingApproval} disabled={pendingApproval} />
<Select
label={t("comm.department")}
value={department}
onChange={(value) => setDepartment(value as DepartmentId | "")}
disabled={pendingApproval}
options={DEPARTMENT_OPTIONS.map((option) => ({
value: option.value,
label: option.value === "" ? t("comm.selectDepartment") : option.label,
}))}
/>
<Select
label={t("agent.status")}
value={status}
+45
View File
@@ -0,0 +1,45 @@
import type { BudgetPolicyRecord } from "@shared/types";
interface BuildBudgetPolicyInputArgs {
policyId?: string | null;
companyId: string;
scopeType: "company" | "agent" | "project";
scopeId: string;
amountDollars: string;
warnPercent: string;
hardStopEnabled: boolean;
notifyEnabled: boolean;
}
export function buildBudgetPolicyInput(args: BuildBudgetPolicyInputArgs) {
const amount = Math.round(Number.parseFloat(args.amountDollars) * 100);
if (!Number.isFinite(amount) || amount <= 0) {
return null;
}
const warnPercent = Number.parseInt(args.warnPercent, 10);
if (!Number.isFinite(warnPercent) || warnPercent < 1 || warnPercent > 100) {
return null;
}
return {
id: args.policyId ?? null,
companyId: args.companyId,
scopeType: args.scopeType,
scopeId: args.scopeId,
metric: "billed_cents" as const,
windowKind: "calendar_month_utc" as const,
amount,
warnPercent,
hardStopEnabled: args.hardStopEnabled,
notifyEnabled: args.notifyEnabled,
isActive: true,
};
}
export function getBudgetPolicyAmountDollars(policy: BudgetPolicyRecord | null): string {
if (!policy) {
return "";
}
return (policy.amount / 100).toFixed(2);
}
+10 -6
View File
@@ -2,12 +2,16 @@ import { z } from "zod";
import { DEPARTMENT_IDS, optionalId } from "./helpers";
import { connectorInputSchema } from "./schemas-company";
const agentNameSchema = z.string().trim().min(2).max(80);
const agentRoleSchema = z.string().trim().min(2).max(80);
const agentTitleSchema = z.string().trim().max(80).default("");
export const agentInputSchema = z.object({
id: optionalId,
companyId: z.string().uuid(),
name: z.string().min(2).max(80),
role: z.string().min(2).max(80),
title: z.string().default(""),
name: agentNameSchema,
role: agentRoleSchema,
title: agentTitleSchema,
department: DEPARTMENT_IDS.nullable().optional(),
reportsTo: optionalId,
connectorId: connectorInputSchema.shape.id,
@@ -22,9 +26,9 @@ export const agentInputSchema = z.object({
export const hireRequestSchema = z.object({
companyId: z.string().uuid(),
requestedByAgentId: optionalId,
name: z.string().min(2).max(80),
role: z.string().min(2).max(80),
title: z.string().default(""),
name: agentNameSchema,
role: agentRoleSchema,
title: agentTitleSchema,
department: DEPARTMENT_IDS.nullable().optional(),
reportsTo: optionalId,
connectorId: connectorInputSchema.shape.id,