Initialize project and ignore local references
This commit is contained in:
1 parent
a11ac3eaba
commit
2af82b6bb3
132 files changed
+29426
No files matched your search
@@ -0,0 +1,62 @@
|
||||
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
|
||||
|
||||
interface TokenPayload {
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
runId: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
function sign(payload: TokenPayload): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signature = createHmac("sha256", SIGNING_KEY)
|
||||
.update(`${header}.${body}`)
|
||||
.digest("base64url");
|
||||
return `${header}.${body}.${signature}`;
|
||||
}
|
||||
|
||||
function verify(token: string): TokenPayload | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const [header, body, signature] = parts as [string, string, string];
|
||||
|
||||
const expectedSignature = createHmac("sha256", SIGNING_KEY)
|
||||
.update(`${header}.${body}`)
|
||||
.digest("base64url");
|
||||
|
||||
const sigBuffer = Buffer.from(signature, "base64url");
|
||||
const expectedBuffer = Buffer.from(expectedSignature, "base64url");
|
||||
if (sigBuffer.length !== expectedBuffer.length || !timingSafeEqual(sigBuffer, expectedBuffer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(body, "base64url").toString()) as TokenPayload;
|
||||
if (Date.now() > payload.exp) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAgentToken(agentId: string, companyId: string, runId: string): string {
|
||||
const now = Date.now();
|
||||
return sign({ agentId, companyId, runId, iat: now, exp: now + TOKEN_TTL_MS });
|
||||
}
|
||||
|
||||
export function validateAgentToken(token: string): TokenPayload | null {
|
||||
return verify(token);
|
||||
}
|
||||
|
||||
export type { TokenPayload };
|
||||
@@ -0,0 +1,839 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import log from "electron-log/main.js";
|
||||
import { z } from "zod";
|
||||
import { validateAgentToken, type TokenPayload } from "./agent-auth";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { agentInputSchema, approvalInputSchema, goalInputSchema, projectInputSchema, taskInputSchema } from "@shared/contracts";
|
||||
|
||||
type RequestHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
params: Record<string, string>,
|
||||
token: TokenPayload,
|
||||
body: unknown,
|
||||
) => void | Promise<void>;
|
||||
|
||||
interface Route {
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
paramNames: string[];
|
||||
handler: RequestHandler;
|
||||
}
|
||||
|
||||
const taskUpdateSchema = z.object({
|
||||
status: z.enum(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]).optional(),
|
||||
comment: z.string().min(1).max(4000).optional(),
|
||||
title: z.string().min(2).max(160).optional(),
|
||||
description: z.string().max(4000).optional(),
|
||||
});
|
||||
|
||||
const checkoutSchema = z.object({
|
||||
agentId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
const approvalListQuerySchema = z.object({
|
||||
requestedByAgentId: z.string().uuid().optional(),
|
||||
relatedTaskId: z.string().uuid().optional(),
|
||||
status: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 class AgentApiServer {
|
||||
private routes: Route[] = [];
|
||||
private server: ReturnType<typeof createServer> | null = null;
|
||||
private port = 0;
|
||||
private db: AppDatabase;
|
||||
private onDomainChanged: () => void;
|
||||
private onWakeAgent: (agentId: string, companyId: string, trigger: string) => void;
|
||||
|
||||
constructor(db: AppDatabase, onDomainChanged: () => void, onWakeAgent: (agentId: string, companyId: string, trigger: string) => void) {
|
||||
this.db = db;
|
||||
this.onDomainChanged = onDomainChanged;
|
||||
this.onWakeAgent = onWakeAgent;
|
||||
this.registerRoutes();
|
||||
}
|
||||
|
||||
getPort(): number {
|
||||
return this.port;
|
||||
}
|
||||
|
||||
async start(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = createServer((req, res) => void this.handleRequest(req, res));
|
||||
this.server.listen(0, "127.0.0.1", () => {
|
||||
const addr = this.server!.address();
|
||||
this.port = typeof addr === "object" && addr ? addr.port : 0;
|
||||
log.info(`[api] Agent API server listening on http://127.0.0.1:${this.port}`);
|
||||
resolve(this.port);
|
||||
});
|
||||
this.server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.server?.close();
|
||||
this.server = null;
|
||||
}
|
||||
|
||||
private addRoute(method: string, path: string, handler: RequestHandler) {
|
||||
const paramNames: string[] = [];
|
||||
const patternStr = path.replace(/:(\w+)/g, (_match, name: string) => {
|
||||
paramNames.push(name);
|
||||
return "([^/]+)";
|
||||
});
|
||||
this.routes.push({ method, pattern: new RegExp(`^${patternStr}$`), paramNames, handler });
|
||||
}
|
||||
|
||||
private async handleRequest(req: IncomingMessage, res: ServerResponse) {
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
const allowedOrigin =
|
||||
origin === "app://-" ||
|
||||
origin.startsWith("http://127.0.0.1:") ||
|
||||
origin.startsWith("http://localhost:");
|
||||
if (!allowedOrigin) {
|
||||
this.json(res, 403, { error: "Origin not allowed" });
|
||||
return;
|
||||
}
|
||||
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||
res.setHeader("Vary", "Origin");
|
||||
}
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Agent-Company-Run-Id");
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// 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" });
|
||||
return;
|
||||
}
|
||||
const token = validateAgentToken(authHeader.slice(7));
|
||||
if (!token) {
|
||||
this.json(res, 401, { error: "Invalid or expired token" });
|
||||
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);
|
||||
if (!match) continue;
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
route.paramNames.forEach((name, i) => {
|
||||
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" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await route.handler(req, res, params, token, body);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`[api] ${method} ${pathname}: ${message}`);
|
||||
this.json(res, 500, { error: message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.json(res, 404, { error: "Not found" });
|
||||
}
|
||||
|
||||
private readBody(req: IncomingMessage): Promise<unknown> {
|
||||
const MAX_BODY_BYTES = 1_048_576; // 1 MB
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
let bytes = 0;
|
||||
req.on("data", (chunk) => {
|
||||
bytes += Buffer.byteLength(chunk);
|
||||
if (bytes > MAX_BODY_BYTES) {
|
||||
req.destroy();
|
||||
reject(new Error("Request body too large"));
|
||||
return;
|
||||
}
|
||||
data += String(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
try { resolve(data ? JSON.parse(data) : null); }
|
||||
catch { reject(new Error("Invalid JSON")); }
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
private json(res: ServerResponse, status: number, data: unknown) {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
private companyRefError(
|
||||
res: ServerResponse,
|
||||
companyId: string,
|
||||
refs: Array<{ id: string | null | undefined; table: "agents" | "goals" | "projects" | "tasks" | "workspaces" | "approvals"; label: string }>,
|
||||
) {
|
||||
const invalid = refs.find((ref) => ref.id && !this.db.belongsToCompany(ref.table, ref.id, companyId));
|
||||
if (!invalid) return false;
|
||||
this.json(res, 422, { error: `${invalid.label} does not belong to the current company` });
|
||||
return true;
|
||||
}
|
||||
|
||||
private registerRoutes() {
|
||||
// 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);
|
||||
const directReports = this.db.getDirectReports(token.agentId, token.companyId);
|
||||
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) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
|
||||
const assigneeAgentId = url.searchParams.get("assigneeAgentId");
|
||||
const statusFilter = url.searchParams.get("status");
|
||||
const statuses = statusFilter ? statusFilter.split(",") : ["todo", "in_progress", "blocked"];
|
||||
|
||||
if (assigneeAgentId) {
|
||||
const tasks = this.db.listAgentTasks(assigneeAgentId, companyId, statuses);
|
||||
this.json(res, 200, tasks);
|
||||
} else {
|
||||
// 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!);
|
||||
if (task.companyId !== token.companyId) {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
this.json(res, 200, task);
|
||||
} catch {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
}
|
||||
});
|
||||
|
||||
// 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) {
|
||||
this.json(res, 400, { error: "Invalid checkout payload" });
|
||||
return;
|
||||
}
|
||||
const agentId = parsedBody.data.agentId ?? token.agentId;
|
||||
if (agentId !== token.agentId) {
|
||||
this.json(res, 403, { error: "Agents can only checkout work for themselves" });
|
||||
return;
|
||||
}
|
||||
let task;
|
||||
try {
|
||||
task = this.db.getTask(params.issueId!);
|
||||
} catch {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
if (task.companyId !== token.companyId) {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
const success = this.db.checkoutTask(params.issueId!, agentId);
|
||||
if (!success) {
|
||||
this.json(res, 409, { error: "Task cannot be checked out — already assigned or not in valid status" });
|
||||
return;
|
||||
}
|
||||
this.onDomainChanged();
|
||||
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) {
|
||||
this.json(res, 400, { error: "Invalid task update payload" });
|
||||
return;
|
||||
}
|
||||
const update = parsedUpdate.data;
|
||||
|
||||
try {
|
||||
const task = this.db.getTask(params.issueId!);
|
||||
if (task.companyId !== token.companyId) {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Update fields
|
||||
const input: Record<string, unknown> = {
|
||||
id: task.id,
|
||||
companyId: task.companyId,
|
||||
title: update.title ?? task.title,
|
||||
description: update.description ?? task.description,
|
||||
status: update.status ?? task.status,
|
||||
priority: task.priority,
|
||||
projectId: task.projectId,
|
||||
goalId: task.goalId,
|
||||
parentId: task.parentId,
|
||||
assigneeAgentId: task.assigneeAgentId,
|
||||
workspaceId: task.workspaceId,
|
||||
};
|
||||
this.db.saveTask(input as Parameters<typeof this.db.saveTask>[0]);
|
||||
|
||||
// Add comment if provided
|
||||
if (update.comment) {
|
||||
const agent = this.db.getAgent(token.agentId);
|
||||
this.db.addComment({
|
||||
companyId: task.companyId,
|
||||
taskId: task.id,
|
||||
authorAgentId: token.agentId,
|
||||
authorName: agent.name,
|
||||
body: update.comment,
|
||||
});
|
||||
}
|
||||
|
||||
this.db.addActivity({
|
||||
companyId: task.companyId,
|
||||
actor: token.agentId,
|
||||
action: `task.${update.status ? "status_changed" : "updated"}`,
|
||||
entityType: "task",
|
||||
entityId: task.id,
|
||||
detail: update.comment ?? `Status → ${update.status ?? task.status}`,
|
||||
});
|
||||
|
||||
this.onDomainChanged();
|
||||
|
||||
// Step 5a: Wake propagation on task status change
|
||||
// Completion notification → task tree (parentId → parent assignee)
|
||||
if (update.status && ["done", "in_review"].includes(update.status)) {
|
||||
if (task.parentId) {
|
||||
try {
|
||||
const parentTask = this.db.getTask(task.parentId);
|
||||
if (parentTask.assigneeAgentId && parentTask.assigneeAgentId !== token.agentId) {
|
||||
this.onWakeAgent(parentTask.assigneeAgentId, task.companyId, "subtask_completed");
|
||||
}
|
||||
} catch { /* parent task may have been deleted */ }
|
||||
} else {
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Blocked escalation → org hierarchy (agent's manager)
|
||||
if (update.status === "blocked") {
|
||||
const chain = this.db.getChainOfCommand(token.agentId);
|
||||
if (chain.length > 0) {
|
||||
this.onWakeAgent(chain[0].id, task.companyId, "report_blocked");
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const wokenForComment = new Set<string>();
|
||||
if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) {
|
||||
this.onWakeAgent(task.assigneeAgentId, task.companyId, "comment");
|
||||
wokenForComment.add(task.assigneeAgentId);
|
||||
}
|
||||
const chain = this.db.getChainOfCommand(token.agentId);
|
||||
if (chain.length > 0 && chain[0].id !== token.agentId && !wokenForComment.has(chain[0].id)) {
|
||||
this.onWakeAgent(chain[0].id, task.companyId, "comment");
|
||||
}
|
||||
}
|
||||
|
||||
this.json(res, 200, { ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("require an assignee")) {
|
||||
this.json(res, 422, { error: error.message });
|
||||
return;
|
||||
}
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
}
|
||||
});
|
||||
|
||||
// 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) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const parsed = taskInputSchema.safeParse({
|
||||
companyId,
|
||||
...(typeof body === "object" && body ? body : {}),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
this.json(res, 400, { error: "Invalid task payload" });
|
||||
return;
|
||||
}
|
||||
const input = parsed.data;
|
||||
if (this.companyRefError(res, companyId, [
|
||||
{ id: input.assigneeAgentId, table: "agents", label: "assignee" },
|
||||
{ id: input.parentId, table: "tasks", label: "parent task" },
|
||||
{ id: input.projectId, table: "projects", label: "project" },
|
||||
{ id: input.goalId, table: "goals", label: "goal" },
|
||||
{ id: input.workspaceId, table: "workspaces", label: "workspace" },
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
|
||||
let id: string;
|
||||
try {
|
||||
id = this.db.saveTask({
|
||||
companyId,
|
||||
title: input.title,
|
||||
description: input.description ?? "",
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
parentId: input.parentId ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
goalId: input.goalId ?? null,
|
||||
priority: input.priority,
|
||||
status: input.status,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
this.json(res, 422, { error: error instanceof Error ? error.message : "Task could not be saved" });
|
||||
return;
|
||||
}
|
||||
|
||||
this.db.addActivity({
|
||||
companyId,
|
||||
actor: token.agentId,
|
||||
action: "task.created",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
detail: input.title,
|
||||
});
|
||||
|
||||
this.onDomainChanged();
|
||||
|
||||
// Auto-cascade: when a task is assigned to an agent, enable their heartbeat and wake them
|
||||
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.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) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const parsed = projectInputSchema.safeParse({
|
||||
companyId,
|
||||
...(typeof body === "object" && body ? body : {}),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
this.json(res, 400, { error: "Invalid project payload" });
|
||||
return;
|
||||
}
|
||||
const input = parsed.data;
|
||||
if (this.companyRefError(res, companyId, [
|
||||
{ id: input.goalId, table: "goals", label: "goal" },
|
||||
{ id: input.leadAgentId, table: "agents", label: "lead agent" },
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = this.db.saveProject({
|
||||
companyId,
|
||||
name: input.name,
|
||||
description: input.description ?? "",
|
||||
goalId: input.goalId ?? null,
|
||||
leadAgentId: input.leadAgentId ?? null,
|
||||
status: (input.status as "planned") ?? "planned",
|
||||
targetDate: input.targetDate ?? null,
|
||||
});
|
||||
|
||||
this.db.addActivity({
|
||||
companyId,
|
||||
actor: token.agentId,
|
||||
action: "project.created",
|
||||
entityType: "project",
|
||||
entityId: id,
|
||||
detail: input.name,
|
||||
});
|
||||
|
||||
this.onDomainChanged();
|
||||
if (input.leadAgentId && input.leadAgentId !== token.agentId) {
|
||||
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.json(res, 201, { id });
|
||||
});
|
||||
|
||||
// POST /api/companies/:companyId/goals — Create goal
|
||||
this.addRoute("POST", "/api/companies/:companyId/goals", (_req, res, params, token, body) => {
|
||||
const companyId = params.companyId!;
|
||||
if (companyId !== token.companyId) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const parsed = goalInputSchema.safeParse({
|
||||
companyId,
|
||||
...(typeof body === "object" && body ? body : {}),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
this.json(res, 400, { error: "Invalid goal payload" });
|
||||
return;
|
||||
}
|
||||
const input = parsed.data;
|
||||
if (this.companyRefError(res, companyId, [
|
||||
{ id: input.parentId, table: "goals", label: "parent goal" },
|
||||
{ id: input.ownerAgentId, table: "agents", label: "owner agent" },
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = this.db.saveGoal(input);
|
||||
this.db.addActivity({
|
||||
companyId,
|
||||
actor: token.agentId,
|
||||
action: "goal.created",
|
||||
entityType: "goal",
|
||||
entityId: id,
|
||||
detail: input.title,
|
||||
});
|
||||
this.onDomainChanged();
|
||||
if (input.ownerAgentId && input.ownerAgentId !== token.agentId && input.status === "active") {
|
||||
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.json(res, 201, { id });
|
||||
});
|
||||
|
||||
// POST /api/companies/:companyId/agents — Propose a hire
|
||||
this.addRoute("POST", "/api/companies/:companyId/agents", (_req, res, params, token, body) => {
|
||||
const companyId = params.companyId!;
|
||||
if (companyId !== token.companyId) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const parsed = agentInputSchema.safeParse({
|
||||
companyId,
|
||||
status: "pending_approval",
|
||||
...(typeof body === "object" && body ? body : {}),
|
||||
reportsTo:
|
||||
typeof body === "object" && body && "reportsTo" in body
|
||||
? (body as { reportsTo?: string | null }).reportsTo
|
||||
: token.agentId,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
this.json(res, 400, { error: "Invalid hire payload" });
|
||||
return;
|
||||
}
|
||||
const input = parsed.data;
|
||||
if (this.companyRefError(res, companyId, [
|
||||
{ id: input.reportsTo, table: "agents", label: "manager" },
|
||||
{ id: input.workspaceId, table: "workspaces", label: "workspace" },
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
|
||||
let agentId: string;
|
||||
try {
|
||||
agentId = this.db.saveAgent({
|
||||
id: input.id ?? null,
|
||||
companyId,
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
title: input.title,
|
||||
status: "pending_approval",
|
||||
reportsTo: input.reportsTo ?? token.agentId,
|
||||
connectorId: input.connectorId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
model: input.model ?? null,
|
||||
metadataJson: input.metadataJson,
|
||||
capabilities: input.capabilities,
|
||||
budgetMonthlyUsd: input.budgetMonthlyUsd,
|
||||
});
|
||||
} catch (error) {
|
||||
this.json(res, 422, { error: error instanceof Error ? error.message : "Hire could not be created" });
|
||||
return;
|
||||
}
|
||||
|
||||
const approvalId = this.db.requestApproval({
|
||||
companyId,
|
||||
requestedByAgentId: token.agentId,
|
||||
type: "hire_agent",
|
||||
payloadSummary: `Hire ${input.name} as ${input.role}`,
|
||||
impactSummary: input.capabilities || `${input.name} will report to ${input.reportsTo ?? token.agentId}.`,
|
||||
payloadJson: JSON.stringify({
|
||||
agentId,
|
||||
requestedByAgentId: token.agentId,
|
||||
requestedConfigurationSnapshot: {
|
||||
connectorId: input.connectorId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
model: input.model ?? null,
|
||||
metadataJson: input.metadataJson,
|
||||
capabilities: input.capabilities,
|
||||
budgetMonthlyUsd: input.budgetMonthlyUsd,
|
||||
reportsTo: input.reportsTo ?? token.agentId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
this.db.addActivity({
|
||||
companyId,
|
||||
actor: token.agentId,
|
||||
action: "agent.hire_requested",
|
||||
entityType: "agent",
|
||||
entityId: agentId,
|
||||
detail: input.name,
|
||||
});
|
||||
this.onDomainChanged();
|
||||
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") {
|
||||
this.json(res, 400, { error: "body is required" });
|
||||
return;
|
||||
}
|
||||
if (input.body.length > 4000) {
|
||||
this.json(res, 400, { error: "Comment body must not exceed 4000 characters" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const task = this.db.getTask(params.issueId!);
|
||||
if (task.companyId !== token.companyId) {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
const agent = this.db.getAgent(token.agentId);
|
||||
const comment = this.db.addComment({
|
||||
companyId: task.companyId,
|
||||
taskId: task.id,
|
||||
authorAgentId: token.agentId,
|
||||
authorName: agent.name,
|
||||
body: input.body,
|
||||
});
|
||||
this.onDomainChanged();
|
||||
|
||||
// Step 5b: Wake task assignee (if not the comment author)
|
||||
const wokenForComment = new Set<string>();
|
||||
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");
|
||||
}
|
||||
|
||||
this.json(res, 201, comment);
|
||||
} catch {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
}
|
||||
});
|
||||
|
||||
// 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!);
|
||||
if (task.companyId !== token.companyId) {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
const comments = this.db.listComments(params.issueId!);
|
||||
this.json(res, 200, comments);
|
||||
});
|
||||
|
||||
// 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) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const parsed = approvalInputSchema.safeParse({
|
||||
companyId,
|
||||
...(typeof body === "object" && body ? body : {}),
|
||||
requestedByAgentId: token.agentId,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
this.json(res, 400, { error: "Invalid approval payload" });
|
||||
return;
|
||||
}
|
||||
const input = parsed.data;
|
||||
if (this.companyRefError(res, companyId, [
|
||||
{ id: input.relatedTaskId, table: "tasks", label: "related task" },
|
||||
{ id: token.agentId, table: "agents", label: "requesting agent" },
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = this.db.requestApproval({
|
||||
companyId,
|
||||
type: input.type,
|
||||
relatedTaskId: input.relatedTaskId ?? null,
|
||||
requestedByAgentId: token.agentId,
|
||||
payloadSummary: input.payloadSummary,
|
||||
impactSummary: input.impactSummary,
|
||||
});
|
||||
|
||||
this.db.addActivity({
|
||||
companyId,
|
||||
actor: token.agentId,
|
||||
action: "approval.created",
|
||||
entityType: "approval",
|
||||
entityId: id,
|
||||
detail: input.payloadSummary,
|
||||
});
|
||||
|
||||
this.onDomainChanged();
|
||||
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) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
|
||||
const parsedQuery = approvalListQuerySchema.safeParse({
|
||||
requestedByAgentId: url.searchParams.get("requestedByAgentId") ?? undefined,
|
||||
relatedTaskId: url.searchParams.get("relatedTaskId") ?? undefined,
|
||||
status: url.searchParams.get("status") ?? undefined,
|
||||
});
|
||||
if (!parsedQuery.success) {
|
||||
this.json(res, 400, { error: "Invalid approval query" });
|
||||
return;
|
||||
}
|
||||
const statuses = parsedQuery.data.status?.split(",").filter(Boolean) ?? null;
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const approvals = snapshot.approvals.filter((approval) => {
|
||||
if (approval.companyId !== companyId) return false;
|
||||
if (parsedQuery.data.requestedByAgentId && approval.requestedByAgentId !== parsedQuery.data.requestedByAgentId) return false;
|
||||
if (parsedQuery.data.relatedTaskId && approval.relatedTaskId !== parsedQuery.data.relatedTaskId) return false;
|
||||
if (statuses && !statuses.includes(approval.state)) return false;
|
||||
return true;
|
||||
});
|
||||
this.json(res, 200, approvals);
|
||||
});
|
||||
|
||||
// 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);
|
||||
if (!approval) {
|
||||
this.json(res, 404, { error: "Approval not found" });
|
||||
return;
|
||||
}
|
||||
this.json(res, 200, approval);
|
||||
});
|
||||
|
||||
// GET /api/companies/:companyId/agents — List agents
|
||||
this.addRoute("GET", "/api/companies/:companyId/agents", (_req, res, params, token) => {
|
||||
if (params.companyId !== token.companyId) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const agents = snapshot.agents.filter((a) => a.companyId === params.companyId);
|
||||
this.json(res, 200, agents);
|
||||
});
|
||||
|
||||
// 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" });
|
||||
return;
|
||||
}
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const goals = snapshot.goals.filter((g) => g.companyId === params.companyId);
|
||||
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" });
|
||||
return;
|
||||
}
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const projects = snapshot.projects.filter((p) => p.companyId === params.companyId);
|
||||
this.json(res, 200, projects);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import type {
|
||||
AdapterExecutionContext,
|
||||
AdapterExecutionResult,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
ServerAdapterModule,
|
||||
} from "@agentcompany/adapter-utils";
|
||||
import { asNumber, asString, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePathInEnv, runChildProcess } from "@agentcompany/adapter-utils/server-utils";
|
||||
import { agentConfigurationDoc as claudeDoc, models as claudeModels } from "@agentcompany/adapter-claude-local";
|
||||
import { execute as claudeExecute, sessionCodec as claudeSessionCodec, testEnvironment as claudeTestEnvironment } from "@agentcompany/adapter-claude-local/server";
|
||||
import {
|
||||
agentConfigurationDoc as codexDoc,
|
||||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
models as codexModels,
|
||||
} from "@agentcompany/adapter-codex-local";
|
||||
import { execute as codexExecute, sessionCodec as codexSessionCodec, testEnvironment as codexTestEnvironment } from "@agentcompany/adapter-codex-local/server";
|
||||
import { agentConfigurationDoc as geminiDoc, DEFAULT_GEMINI_LOCAL_MODEL, models as geminiModels } from "@agentcompany/adapter-gemini-local";
|
||||
import { execute as geminiExecute, sessionCodec as geminiSessionCodec, testEnvironment as geminiTestEnvironment } from "@agentcompany/adapter-gemini-local/server";
|
||||
import { agentConfigurationDoc as opencodeDoc } from "@agentcompany/adapter-opencode-local";
|
||||
import { execute as opencodeExecute, listOpenCodeModels, sessionCodec as opencodeSessionCodec, testEnvironment as opencodeTestEnvironment } from "@agentcompany/adapter-opencode-local/server";
|
||||
import type { CapabilityMatrix, ConnectorId, ConnectorRecord } from "@shared/types";
|
||||
|
||||
function normalizeEnv(input: NodeJS.ProcessEnv): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(input).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectorDefinition {
|
||||
id: ConnectorId;
|
||||
label: string;
|
||||
description: string;
|
||||
defaultCommand: string;
|
||||
defaultModel: string | null;
|
||||
notes: string;
|
||||
capabilityMatrix: CapabilityMatrix;
|
||||
configurationDoc: string;
|
||||
module: ServerAdapterModule;
|
||||
listModels?: () => Promise<Array<{ id: string; label: string }>>;
|
||||
}
|
||||
|
||||
const kimiModule: ServerAdapterModule = {
|
||||
type: "kimi_local",
|
||||
async testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult> {
|
||||
const command = asString(ctx.config.command, "kimi");
|
||||
const cwd = asString(ctx.config.cwd, process.cwd());
|
||||
const env = normalizeEnv(ensurePathInEnv({ ...(ctx.config.env as Record<string, string> ?? process.env) }));
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, env);
|
||||
return {
|
||||
adapterType: "kimi_local",
|
||||
status: "warn",
|
||||
checks: [
|
||||
{
|
||||
code: "kimi_command_detected",
|
||||
level: "info",
|
||||
message: `Detected Kimi Code command "${command}".`,
|
||||
},
|
||||
{
|
||||
code: "kimi_runtime_generic_mode",
|
||||
level: "warn",
|
||||
message:
|
||||
"Kimi integration is running in generic CLI mode. Session resume, model discovery, and structured transcript are not guaranteed.",
|
||||
},
|
||||
],
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
adapterType: "kimi_local",
|
||||
status: "fail",
|
||||
checks: [
|
||||
{
|
||||
code: "kimi_command_missing",
|
||||
level: "error",
|
||||
message: error instanceof Error ? error.message : "Kimi command is not available.",
|
||||
hint: "Install Kimi Code and ensure the CLI is available on PATH.",
|
||||
},
|
||||
],
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
},
|
||||
async execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const command = asString(ctx.config.command, "kimi");
|
||||
const cwd = asString(ctx.config.cwd, process.cwd());
|
||||
const model = asString(ctx.config.model, "kimi-for-coding");
|
||||
const timeoutSec = asNumber(ctx.config.timeoutSec, 0);
|
||||
const graceSec = asNumber(ctx.config.graceSec, 20);
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
|
||||
const prompt = typeof ctx.context.taskTitle === "string" && ctx.context.taskTitle.length > 0
|
||||
? [
|
||||
ctx.context.taskTitle,
|
||||
typeof ctx.context.taskDescription === "string" && ctx.context.taskDescription.length > 0
|
||||
? ctx.context.taskDescription
|
||||
: "",
|
||||
].filter(Boolean).join("\n\n")
|
||||
: [
|
||||
`You are agent ${ctx.agent.name}.`,
|
||||
"Respond with a concise execution summary when complete.",
|
||||
].join("\n\n");
|
||||
|
||||
const env = normalizeEnv(ensurePathInEnv({ ...(ctx.config.env as Record<string, string> ?? process.env) }));
|
||||
await ensureCommandResolvable(command, cwd, env);
|
||||
const result = await runChildProcess(ctx.runId, command, [], {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
stdin: prompt,
|
||||
onLog: ctx.onLog,
|
||||
});
|
||||
|
||||
const summary =
|
||||
result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-8)
|
||||
.join("\n") || null;
|
||||
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
timedOut: result.timedOut,
|
||||
summary,
|
||||
model,
|
||||
billingType: "unknown",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const CONNECTORS: ConnectorDefinition[] = [
|
||||
{
|
||||
id: "codex_local",
|
||||
label: "Codex",
|
||||
description: "Local Codex CLI with session resume, structured event parsing, and model selection.",
|
||||
defaultCommand: "codex",
|
||||
defaultModel: DEFAULT_CODEX_LOCAL_MODEL,
|
||||
notes: "Best fit for high-context coding tasks and shell-heavy automation.",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: codexDoc,
|
||||
module: {
|
||||
type: "codex_local",
|
||||
execute: codexExecute,
|
||||
testEnvironment: codexTestEnvironment,
|
||||
sessionCodec: codexSessionCodec,
|
||||
models: codexModels,
|
||||
},
|
||||
listModels: async () => codexModels,
|
||||
},
|
||||
{
|
||||
id: "claude_local",
|
||||
label: "Claude Code",
|
||||
description: "Local Claude Code CLI with session persistence and structured result parsing.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: claudeModels[0]?.id ?? null,
|
||||
notes: "Useful for long-running coding sessions and agent-style loops.",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: true,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "gemini_local",
|
||||
label: "Gemini CLI",
|
||||
description: "Local Gemini CLI with resume support and JSON event parsing.",
|
||||
defaultCommand: "gemini",
|
||||
defaultModel: DEFAULT_GEMINI_LOCAL_MODEL,
|
||||
notes: "Good for multi-provider local workflows when Google auth is already in place.",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: true,
|
||||
},
|
||||
configurationDoc: geminiDoc,
|
||||
module: {
|
||||
type: "gemini_local",
|
||||
execute: geminiExecute,
|
||||
testEnvironment: geminiTestEnvironment,
|
||||
sessionCodec: geminiSessionCodec,
|
||||
models: geminiModels,
|
||||
},
|
||||
listModels: async () => geminiModels,
|
||||
},
|
||||
{
|
||||
id: "opencode_local",
|
||||
label: "OpenCode",
|
||||
description: "Local OpenCode runtime with provider/model routing and session restore.",
|
||||
defaultCommand: "opencode",
|
||||
defaultModel: null,
|
||||
notes: "Best when model routing belongs to the runtime rather than the app.",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: true,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: opencodeDoc,
|
||||
module: {
|
||||
type: "opencode_local",
|
||||
execute: opencodeExecute,
|
||||
testEnvironment: opencodeTestEnvironment,
|
||||
sessionCodec: opencodeSessionCodec,
|
||||
models: [],
|
||||
listModels: listOpenCodeModels,
|
||||
},
|
||||
listModels: listOpenCodeModels,
|
||||
},
|
||||
{
|
||||
id: "kimi_local",
|
||||
label: "Kimi Code",
|
||||
description: "Official Kimi Code CLI surfaced through a generic execution contract with explicit capability downgrade.",
|
||||
defaultCommand: "kimi",
|
||||
defaultModel: "kimi-for-coding",
|
||||
notes: "MVP support is explicit generic mode until richer runtime contract evidence is implemented.",
|
||||
capabilityMatrix: {
|
||||
sessionResume: false,
|
||||
gracefulCancel: false,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: false,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: [
|
||||
"# kimi_local agent configuration",
|
||||
"",
|
||||
"Core fields:",
|
||||
"- command (string, optional): defaults to \"kimi\"",
|
||||
"- cwd (string, optional): working directory for the Kimi CLI process",
|
||||
"- model (string, optional): default kimi-for-coding",
|
||||
"- timeoutSec (number, optional)",
|
||||
"- graceSec (number, optional)",
|
||||
"",
|
||||
"Notes:",
|
||||
"- This connector currently runs Kimi in generic CLI mode.",
|
||||
"- Session resume, model discovery, structured transcript, and cost attribution are declared unsupported.",
|
||||
"- The UI must surface these limitations explicitly.",
|
||||
].join("\n"),
|
||||
module: kimiModule,
|
||||
listModels: async () => [{ id: "kimi-for-coding", label: "kimi-for-coding" }],
|
||||
},
|
||||
];
|
||||
|
||||
export function listConnectorDefinitions(): ConnectorDefinition[] {
|
||||
return CONNECTORS;
|
||||
}
|
||||
|
||||
export function getConnectorDefinition(id: ConnectorId): ConnectorDefinition {
|
||||
const definition = CONNECTORS.find((entry) => entry.id === id);
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown connector: ${id}`);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function parseEnvBindingText(text: string): Record<string, string> {
|
||||
const bindings: Record<string, string> = {};
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const [envKey, ...rest] = line.split("=");
|
||||
const key = envKey?.trim();
|
||||
const value = rest.join("=").trim();
|
||||
if (!key || !value) continue;
|
||||
bindings[key] = value;
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
export function buildDefaultConnectorRows(existing: Array<Partial<ConnectorRecord>> = []): ConnectorRecord[] {
|
||||
const byId = new Map(existing.map((record) => [record.id, record]));
|
||||
return CONNECTORS.map((definition) => {
|
||||
const existingRecord = byId.get(definition.id);
|
||||
return {
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
status: existingRecord?.status ?? "not_installed",
|
||||
command: definition.defaultCommand,
|
||||
version: existingRecord?.version ?? null,
|
||||
authState: existingRecord?.authState ?? "unknown",
|
||||
lastCheckedAt: existingRecord?.lastCheckedAt ?? null,
|
||||
lastError: existingRecord?.lastError ?? null,
|
||||
model: existingRecord?.model ?? definition.defaultModel,
|
||||
envBindingText: existingRecord?.envBindingText ?? "",
|
||||
notes: existingRecord?.notes ?? definition.notes,
|
||||
configurationDoc: definition.configurationDoc,
|
||||
capabilityMatrix: definition.capabilityMatrix,
|
||||
};
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+1935
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,321 @@
|
||||
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<string> = 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<string, string>,
|
||||
secretBindings: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const key of ENV_ALLOWLIST) {
|
||||
if (key in hostEnv) {
|
||||
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<string, string>): (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;
|
||||
for (const pattern of sensitivePatterns) {
|
||||
if (result.includes(pattern)) {
|
||||
result = result.replaceAll(pattern, "[REDACTED]");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
interface RunJob {
|
||||
runId: string;
|
||||
companyId: string;
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
taskDescription: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
connectorId: ConnectorId;
|
||||
workspaceId: string | null;
|
||||
workspacePath: string | null;
|
||||
command: string;
|
||||
model: string | null;
|
||||
thinkingEffort: string | null;
|
||||
env: Record<string, string>;
|
||||
sessionParams: Record<string, unknown> | null;
|
||||
/** Rich system prompt with API instructions for autonomous operation */
|
||||
systemPrompt: string | null;
|
||||
/** Heartbeat trigger that caused this run */
|
||||
trigger: string | null;
|
||||
}
|
||||
|
||||
type ParentMessage =
|
||||
| { type: "enqueue-run"; payload: RunJob }
|
||||
| { type: "cancel-run"; runId: string };
|
||||
|
||||
type ChildMessage =
|
||||
| { type: "run-log"; runId: string; stream: "stdout" | "stderr"; chunk: string }
|
||||
| { type: "run-status"; runId: string; status: RunStatus; message: string }
|
||||
| {
|
||||
type: "run-finished";
|
||||
runId: string;
|
||||
status: RunStatus;
|
||||
summary: string | null;
|
||||
errorMessage: string | null;
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
model: string | null;
|
||||
sessionParams: Record<string, unknown> | null;
|
||||
sessionDisplayId: string | null;
|
||||
costUsd: number | null;
|
||||
unattributedCost: boolean;
|
||||
};
|
||||
|
||||
const parentPort = process.parentPort;
|
||||
|
||||
if (!parentPort) {
|
||||
throw new Error("AgentCompany worker requires process.parentPort");
|
||||
}
|
||||
|
||||
const activeByAgent = new Map<string, string>();
|
||||
const activeByWorkspace = new Map<string, string>();
|
||||
const queue: RunJob[] = [];
|
||||
const cancelledRuns = new Set<string>();
|
||||
|
||||
function post(message: ChildMessage) {
|
||||
parentPort.postMessage(message);
|
||||
}
|
||||
|
||||
function workspaceKey(job: RunJob) {
|
||||
return job.workspaceId ?? job.workspacePath ?? `task:${job.taskId}`;
|
||||
}
|
||||
|
||||
function enqueue(job: RunJob) {
|
||||
queue.push(job);
|
||||
post({
|
||||
type: "run-status",
|
||||
runId: job.runId,
|
||||
status: "queued",
|
||||
message: "Run queued in orchestration worker.",
|
||||
});
|
||||
void maybeStartQueuedRuns();
|
||||
}
|
||||
|
||||
async function maybeStartQueuedRuns() {
|
||||
const readyJobs = [...queue];
|
||||
for (const job of readyJobs) {
|
||||
if (activeByAgent.has(job.agentId)) continue;
|
||||
if (activeByWorkspace.has(workspaceKey(job))) continue;
|
||||
const index = queue.findIndex((entry) => entry.runId === job.runId);
|
||||
if (index >= 0) queue.splice(index, 1);
|
||||
void startRun(job);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
post({
|
||||
type: "run-status",
|
||||
runId: job.runId,
|
||||
status: "running",
|
||||
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"),
|
||||
),
|
||||
job.env,
|
||||
);
|
||||
|
||||
// Build log redactor from secret bindings to prevent accidental secret leakage
|
||||
const redact = buildRedactor(job.env);
|
||||
|
||||
try {
|
||||
const result = await definition.module.execute({
|
||||
runId: job.runId,
|
||||
agent: {
|
||||
id: job.agentId,
|
||||
companyId: job.companyId,
|
||||
name: job.agentName,
|
||||
adapterType: job.connectorId,
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId:
|
||||
typeof job.sessionParams?.sessionId === "string" ? job.sessionParams.sessionId : null,
|
||||
sessionParams: job.sessionParams,
|
||||
sessionDisplayId:
|
||||
typeof job.sessionParams?.sessionId === "string" ? job.sessionParams.sessionId : null,
|
||||
taskKey: job.taskId,
|
||||
},
|
||||
config: {
|
||||
command: job.command,
|
||||
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"] : [],
|
||||
},
|
||||
context: {
|
||||
taskId: job.taskId,
|
||||
taskTitle: job.systemPrompt ?? job.taskTitle,
|
||||
taskDescription: job.systemPrompt ? "" : job.taskDescription,
|
||||
agentCompanyWorkspace: job.workspaceId || job.workspacePath ? {
|
||||
workspaceId: job.workspaceId ?? "",
|
||||
cwd: job.workspacePath ?? "",
|
||||
source: "agent_company",
|
||||
} : undefined,
|
||||
},
|
||||
onLog: async (stream, chunk) => {
|
||||
post({
|
||||
type: "run-log",
|
||||
runId: job.runId,
|
||||
stream,
|
||||
chunk: redact(chunk),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const cancelled = cancelledRuns.has(job.runId);
|
||||
const finalStatus = cancelled
|
||||
? "cancelled"
|
||||
: result.timedOut
|
||||
? "timed_out"
|
||||
: result.errorMessage || result.exitCode
|
||||
? "failed"
|
||||
: "succeeded";
|
||||
|
||||
post({
|
||||
type: "run-finished",
|
||||
runId: job.runId,
|
||||
status: finalStatus,
|
||||
summary: result.summary ?? null,
|
||||
errorMessage: result.errorMessage ?? null,
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
model: result.model ?? job.model,
|
||||
sessionParams: result.sessionParams ?? null,
|
||||
sessionDisplayId: result.sessionDisplayId ?? null,
|
||||
costUsd: typeof result.costUsd === "number" ? result.costUsd : null,
|
||||
unattributedCost: !result.costUsd,
|
||||
});
|
||||
} catch (error) {
|
||||
post({
|
||||
type: "run-finished",
|
||||
runId: job.runId,
|
||||
status: cancelledRuns.has(job.runId) ? "cancelled" : "failed",
|
||||
summary: null,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: job.model,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
costUsd: null,
|
||||
unattributedCost: true,
|
||||
});
|
||||
} finally {
|
||||
cancelledRuns.delete(job.runId);
|
||||
activeByAgent.delete(job.agentId);
|
||||
activeByWorkspace.delete(lockKey);
|
||||
void maybeStartQueuedRuns();
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRun(runId: string) {
|
||||
const queuedIndex = queue.findIndex((entry) => entry.runId === runId);
|
||||
if (queuedIndex >= 0) {
|
||||
queue.splice(queuedIndex, 1);
|
||||
post({
|
||||
type: "run-finished",
|
||||
runId,
|
||||
status: "cancelled",
|
||||
summary: "Cancelled before execution.",
|
||||
errorMessage: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
costUsd: null,
|
||||
unattributedCost: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
cancelledRuns.add(runId);
|
||||
const running = runningProcesses.get(runId);
|
||||
if (running) {
|
||||
running.child.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
parentPort.on("message", (event) => {
|
||||
const message = event.data as ParentMessage;
|
||||
if (message.type === "enqueue-run") {
|
||||
enqueue(message.payload);
|
||||
return;
|
||||
}
|
||||
if (message.type === "cancel-run") {
|
||||
cancelRun(message.runId);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
import type { AgentRecord, ApprovalRecord, GoalRecord, ProjectRecord, TaskRecord } from "@shared/types";
|
||||
|
||||
interface PromptContext {
|
||||
agent: AgentRecord;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
runId: string;
|
||||
task: TaskRecord | null;
|
||||
companyName: string;
|
||||
goals: GoalRecord[];
|
||||
projects: ProjectRecord[];
|
||||
directReports: AgentRecord[];
|
||||
chainOfCommand: AgentRecord[];
|
||||
assignedTasks: TaskRecord[];
|
||||
allAgents: AgentRecord[];
|
||||
pendingApprovals: ApprovalRecord[];
|
||||
/** Tasks from direct reports that are awaiting this agent's review */
|
||||
tasksAwaitingReview: TaskRecord[];
|
||||
wakeReason: string | null;
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(ctx: PromptContext): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
// Identity
|
||||
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}` : ""}
|
||||
`);
|
||||
|
||||
// Organization context
|
||||
if (ctx.chainOfCommand.length > 0) {
|
||||
sections.push(`# Chain of Command
|
||||
You report to: ${ctx.chainOfCommand.map((a) => `${a.name} (${a.role})`).join(" → ")}
|
||||
`);
|
||||
} else {
|
||||
sections.push(`# Position
|
||||
You are the top-level executive. You report directly to the human operator (Board).
|
||||
`);
|
||||
}
|
||||
|
||||
if (ctx.directReports.length > 0) {
|
||||
sections.push(`# Your Direct Reports
|
||||
${ctx.directReports.map((a) => `- ${a.name}: ${a.role}${a.title ? ` (${a.title})` : ""}${a.capabilities ? ` — ${a.capabilities}` : ""} [status: ${a.status}, connector: ${a.connectorId}]`).join("\n")}
|
||||
`);
|
||||
}
|
||||
|
||||
// Active goals
|
||||
const activeGoals = ctx.goals.filter((g) => g.status === "active" || g.status === "planned");
|
||||
if (activeGoals.length > 0) {
|
||||
sections.push(`# Active Goals
|
||||
${activeGoals.map((g) => `- [${g.id}] ${g.title}: ${g.description || "No description"} (status: ${g.status}${g.ownerAgentId === ctx.agent.id ? " — YOU OWN THIS" : ""})`).join("\n")}
|
||||
`);
|
||||
}
|
||||
|
||||
// Active projects
|
||||
const relevantProjects = ctx.projects.filter((p) =>
|
||||
p.status === "in_progress" || p.status === "planned" ||
|
||||
p.leadAgentId === ctx.agent.id,
|
||||
);
|
||||
if (relevantProjects.length > 0) {
|
||||
sections.push(`# Projects
|
||||
${relevantProjects.map((p) => `- [${p.id}] ${p.name}: ${p.description || ""} (status: ${p.status}${p.leadAgentId === ctx.agent.id ? " — YOU LEAD THIS" : ""})`).join("\n")}
|
||||
`);
|
||||
}
|
||||
|
||||
// Current task
|
||||
if (ctx.task) {
|
||||
sections.push(`# Current Task
|
||||
- ID: ${ctx.task.id}
|
||||
- Title: ${ctx.task.title}
|
||||
- Description: ${ctx.task.description || "No description"}
|
||||
- Status: ${ctx.task.status}
|
||||
- Priority: ${ctx.task.priority}
|
||||
${ctx.task.projectId ? `- Project: ${ctx.projects.find((p) => p.id === ctx.task!.projectId)?.name ?? ctx.task.projectId}` : ""}
|
||||
${ctx.task.goalId ? `- Goal: ${ctx.goals.find((g) => g.id === ctx.task!.goalId)?.title ?? 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) => {
|
||||
const assigneeName = ctx.allAgents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown";
|
||||
return `- [${t.id}] ${t.title} (by ${assigneeName}, priority: ${t.priority})`;
|
||||
}).join("\n")}
|
||||
|
||||
These tasks have been submitted for your review by your direct reports. Read their comments, then either:
|
||||
- Approve: PATCH /api/issues/{issueId} with status "done" and an approving comment
|
||||
- Request changes: PATCH /api/issues/{issueId} with status "in_progress" and feedback comment
|
||||
`);
|
||||
}
|
||||
|
||||
// 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")}
|
||||
`);
|
||||
}
|
||||
|
||||
// API instructions
|
||||
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.
|
||||
|
||||
## Available Endpoints
|
||||
|
||||
### Get Your Identity
|
||||
GET /api/agents/me
|
||||
Returns your agent record with chain of command.
|
||||
|
||||
### List Your Assigned Tasks
|
||||
GET /api/companies/{companyId}/issues?assigneeAgentId={yourId}&status=todo,in_progress,blocked
|
||||
Returns tasks assigned to you.
|
||||
|
||||
### 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
|
||||
Body: {
|
||||
"title": "Task title",
|
||||
"description": "Task description",
|
||||
"assigneeAgentId": "{reportId}",
|
||||
"parentId": "{parentIssueId}",
|
||||
"projectId": "{projectId}",
|
||||
"goalId": "{goalId}",
|
||||
"priority": "medium",
|
||||
"status": "todo"
|
||||
}
|
||||
Always set parentId to maintain task hierarchy.
|
||||
|
||||
### Create a Project
|
||||
POST /api/companies/{companyId}/projects
|
||||
Body: {
|
||||
"name": "Project name",
|
||||
"description": "Project description",
|
||||
"goalId": "{goalId}",
|
||||
"leadAgentId": "{agentId}",
|
||||
"status": "planned"
|
||||
}
|
||||
|
||||
### Create a Goal
|
||||
POST /api/companies/{companyId}/goals
|
||||
Body: {
|
||||
"title": "Goal title",
|
||||
"description": "Definition of done",
|
||||
"parentId": "{optionalParentGoalId}",
|
||||
"ownerAgentId": "{agentId}",
|
||||
"status": "active"
|
||||
}
|
||||
|
||||
### Propose a Hire
|
||||
POST /api/companies/{companyId}/agents
|
||||
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
|
||||
}
|
||||
This creates the agent in pending approval state and opens a hire approval for the Board.
|
||||
|
||||
### 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
|
||||
Body: {
|
||||
"type": "secret_access",
|
||||
"relatedTaskId": "{taskId}",
|
||||
"requestedByAgentId": "${ctx.agent.id}",
|
||||
"payloadSummary": "Need access to X account credentials",
|
||||
"impactSummary": "Required for social media marketing campaign"
|
||||
}
|
||||
Approval types: hire_agent, approve_ceo_strategy, dangerous_command, secret_access
|
||||
|
||||
### Check Approval Status
|
||||
GET /api/approvals/{approvalId}
|
||||
|
||||
### List Company Approvals
|
||||
GET /api/companies/{companyId}/approvals?requestedByAgentId={yourId}&status=pending,approved,revision_requested
|
||||
|
||||
### List All Agents
|
||||
GET /api/companies/{companyId}/agents
|
||||
|
||||
### List Company Goals
|
||||
GET /api/companies/{companyId}/goals
|
||||
|
||||
### List Company Projects
|
||||
GET /api/companies/{companyId}/projects
|
||||
`);
|
||||
|
||||
// Step 1: CEO role detection fix — use chainOfCommand, not directReports
|
||||
const isCEO = ctx.chainOfCommand.length === 0;
|
||||
const isExecutive = isCEO;
|
||||
const isManager = !isCEO && ctx.directReports.length > 0;
|
||||
|
||||
if (isExecutive) {
|
||||
// Step 2c: CEO phased prompts
|
||||
// Filter to active reports only (exclude pending_approval, terminated)
|
||||
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.
|
||||
|
||||
Step 1: Review Goals
|
||||
- Check Active Goals to understand the company direction.
|
||||
|
||||
Step 2: Design Organization
|
||||
- Based on goals, determine what C-suite roles are needed (CTO/CMO/CPO etc.)
|
||||
- Use POST /api/companies/{companyId}/agents to submit hire requests
|
||||
- Each role needs clear capabilities, connector, workspace
|
||||
|
||||
Step 3: Submit Org Plan for Approval
|
||||
- Create an approve_ceo_strategy approval with your org structure plan
|
||||
- Use GET /api/approvals/{id} to check approval status
|
||||
- Do NOT delegate work or create projects until the Board approves
|
||||
|
||||
IMPORTANT: You MUST wait for the Board (human operator) to approve your org structure before starting 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.
|
||||
Check your pending approvals. If revision_requested, modify your team structure accordingly.
|
||||
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.
|
||||
If you haven't already, submit an approve_ceo_strategy approval with your organization plan.
|
||||
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. Your primary responsibilities:
|
||||
|
||||
1. **Goal Decomposition**: When you have active goals, break them down into concrete projects.
|
||||
- Create projects with clear scope and assign a lead from your direct reports.
|
||||
- Each project should have a goal linked to it.
|
||||
|
||||
2. **Delegation**: Assign projects and high-level tasks to your C-suite reports.
|
||||
- Match tasks to the agent whose role fits best.
|
||||
- CTO/Engineering Lead → technical tasks
|
||||
- CMO/Marketing Lead → marketing, growth, content tasks
|
||||
- CPO/Product Lead → product design, UX, feature scoping
|
||||
- Other roles → match by capabilities description
|
||||
|
||||
3. **Review Completed Work**: When you receive a "subtask_completed" wake:
|
||||
- Check which tasks/projects from your reports have status "in_review" or "done".
|
||||
- Read comments on those tasks to understand what was accomplished.
|
||||
- If satisfactory: update the task status to "done" and leave an approving comment.
|
||||
- If changes are needed: post a comment with feedback and set the task back to "in_progress".
|
||||
|
||||
4. **Coordination**: Check on project progress. Read comments from your reports.
|
||||
If a report is blocked or needs credentials, either:
|
||||
a. Resolve it yourself if you can
|
||||
b. Escalate to the Board by creating an approval request
|
||||
|
||||
5. **Credential Escalation**: When a report needs account access:
|
||||
- Create a secret_access approval for the Board
|
||||
- After approval, notify the requesting agent via comment
|
||||
|
||||
6. **Hiring**: When the team needs additional members:
|
||||
- Use POST /api/companies/{companyId}/agents to propose new hires
|
||||
- New hires will be pending Board approval
|
||||
- Assign them to report to the appropriate C-suite lead
|
||||
|
||||
7. **Reporting**: After each run, leave a summary comment on your primary task.
|
||||
|
||||
IMPORTANT: Do NOT try to do all the work yourself. Your job is to DELEGATE, REVIEW, and COORDINATE.
|
||||
`);
|
||||
}
|
||||
} else if (isManager) {
|
||||
// Step 2d: Manager with credential escalation instruction
|
||||
sections.push(`# Operating Instructions (Manager)
|
||||
|
||||
You are a middle manager. Your primary responsibilities:
|
||||
|
||||
1. **Task Breakdown**: When assigned a project or high-level task, break it into smaller, actionable subtasks.
|
||||
- Use POST /api/companies/{companyId}/issues to create subtasks.
|
||||
- Set assigneeAgentId to one of your direct reports.
|
||||
- Set parentId to link subtasks to the parent task.
|
||||
|
||||
2. **Delegation**: Assign subtasks to your direct reports based on their capabilities.
|
||||
- Match each report's capabilities to the right subtask.
|
||||
- Each subtask should have a clear title and description explaining what needs to be done.
|
||||
|
||||
3. **Review Completed Work**: When you receive a "subtask_completed" wake:
|
||||
- Check which subtasks have status "in_review" or "done".
|
||||
- Read comments on those tasks to understand what was done.
|
||||
- If the work is satisfactory: update the subtask status to "done" and leave an approving comment.
|
||||
- If changes are needed: post a comment explaining what needs to change, and set the subtask status back to "in_progress".
|
||||
- This is your core management duty — do NOT skip reviews.
|
||||
|
||||
4. **Ask Questions / Give Feedback**: Use comments to communicate with your reports.
|
||||
- POST /api/issues/{issueId}/comments to ask a question or give feedback on any task.
|
||||
- The task assignee will be automatically woken and notified.
|
||||
|
||||
5. **Progress Tracking**: Check comments and task statuses of your reports.
|
||||
- GET /api/companies/{companyId}/issues to see all tasks and their statuses.
|
||||
- GET /api/issues/{issueId}/comments to read discussion on specific tasks.
|
||||
|
||||
6. **Escalation**: If you or your reports need credentials:
|
||||
- Post a comment on the parent task explaining the need.
|
||||
- Your manager will be notified and escalate further.
|
||||
- Do NOT create secret_access approvals directly — escalate upward.
|
||||
|
||||
7. **Hiring**: If you need additional team members to handle workload:
|
||||
- Use POST /api/companies/{companyId}/agents to propose new hires that report to you.
|
||||
- New hires require Board approval before they can start working.
|
||||
|
||||
8. **Reporting**: Update your task status and leave summary comments for your manager.
|
||||
`);
|
||||
} else {
|
||||
// Step 2d: IC with credential escalation instruction
|
||||
sections.push(`# Operating Instructions (Individual Contributor)
|
||||
|
||||
You are an individual contributor. Your primary responsibilities:
|
||||
|
||||
1. **Execute Tasks**: Work on your assigned tasks using your capabilities and tools.
|
||||
- Focus on tasks that match your role and capabilities.
|
||||
- Use the tools available in your workspace (CLI, code editors, etc.) to produce deliverables.
|
||||
|
||||
2. **Checkout First**: Always checkout a task before starting work on it.
|
||||
- POST /api/issues/{issueId}/checkout to claim the task.
|
||||
|
||||
3. **Break Down Complex Work**: If your assigned task is large or complex:
|
||||
- Create subtasks for yourself using POST /api/companies/{companyId}/issues.
|
||||
- Set assigneeAgentId to your own ID and parentId to the original task.
|
||||
- This helps you track progress on multi-step work.
|
||||
|
||||
4. **Report Progress**: Leave comments on your tasks with progress updates.
|
||||
- POST /api/issues/{issueId}/comments with a summary of what you did.
|
||||
- Your manager will be automatically notified of your comments.
|
||||
|
||||
5. **Submit for Review**: When your work is complete, update the task status to "in_review".
|
||||
- PATCH /api/issues/{issueId} with status "in_review" and a comment describing what was done.
|
||||
- Your manager will be automatically notified to review your work.
|
||||
- Wait for your manager's feedback before marking as "done".
|
||||
|
||||
6. **Ask Questions**: If requirements are unclear or you need guidance:
|
||||
- Post a comment on your task with your question.
|
||||
- Your manager will be notified and can respond.
|
||||
- Check comments on your task for any feedback or answers.
|
||||
|
||||
7. **Handle Review Feedback**: When your manager posts a comment or sets your task back to "in_progress":
|
||||
- Read the comment to understand what changes are needed.
|
||||
- Make the changes and submit for review again.
|
||||
|
||||
8. **Report Blockers**: If you're blocked (need credentials, unclear requirements, etc.):
|
||||
- Update task status to "blocked"
|
||||
- Leave a comment explaining what's needed
|
||||
- Your manager will be notified
|
||||
|
||||
9. **Request Credentials**: If you need credentials (API keys, account access, etc.):
|
||||
- Post a comment on your task explaining what you need and why
|
||||
- Your manager will be notified and help resolve it
|
||||
- Only create a secret_access approval directly if you have NO manager
|
||||
|
||||
10. **Propose Hires**: If you believe the team needs additional members:
|
||||
- Use POST /api/companies/{companyId}/agents to propose a new hire.
|
||||
- Set reportsTo to your manager's agent ID.
|
||||
- The Board must approve before the hire becomes active.
|
||||
`);
|
||||
}
|
||||
|
||||
sections.push(`# Important Rules
|
||||
- Company ID: ${ctx.agent.companyId}
|
||||
- Your Agent ID: ${ctx.agent.id}
|
||||
- Current Run ID: ${ctx.runId}
|
||||
- Always include appropriate headers when calling the API:
|
||||
Authorization: Bearer $AGENT_COMPANY_API_KEY
|
||||
Content-Type: application/json
|
||||
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.
|
||||
- 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.
|
||||
`);
|
||||
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
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") {
|
||||
const reasonMap: Record<string, string> = {
|
||||
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.",
|
||||
};
|
||||
const explanation = reasonMap[ctx.wakeReason] ?? `Trigger: ${ctx.wakeReason}`;
|
||||
parts.push(`\n# Wake Reason\n${explanation}\n`);
|
||||
}
|
||||
|
||||
if (ctx.task) {
|
||||
parts.push(`\n---\n\nYour immediate task:\n\nTitle: ${ctx.task.title}\nDescription: ${ctx.task.description || "No additional description."}\nPriority: ${ctx.task.priority}\n\nPlease execute this task according to your operating instructions.`);
|
||||
} else {
|
||||
parts.push(`\n---\n\nYou have been woken for a heartbeat check. Review your assigned tasks and active goals, then take appropriate action. If there's nothing to do, respond with a brief status summary.`);
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { safeStorage } from "electron";
|
||||
|
||||
export class SecretVault {
|
||||
private readonly keyPath: string;
|
||||
|
||||
constructor(private readonly profileDir: string) {
|
||||
this.keyPath = join(profileDir, "vault.key");
|
||||
}
|
||||
|
||||
getBackendLabel(): string {
|
||||
if (this.canUseSafeStorage()) {
|
||||
const selectedBackend = typeof safeStorage?.getSelectedStorageBackend === "function"
|
||||
? safeStorage.getSelectedStorageBackend()
|
||||
: "platform";
|
||||
if (selectedBackend && selectedBackend !== "basic_text") {
|
||||
return `safe_storage:${selectedBackend}`;
|
||||
}
|
||||
}
|
||||
return this.allowInsecureVaultFallback() ? "vault:aes-256-gcm" : "unavailable";
|
||||
}
|
||||
|
||||
encrypt(value: string): { backend: "safe_storage" | "vault"; payload: Buffer } {
|
||||
if (this.canUseSafeStorage()) {
|
||||
const selectedBackend = typeof safeStorage?.getSelectedStorageBackend === "function"
|
||||
? safeStorage.getSelectedStorageBackend()
|
||||
: "platform";
|
||||
if (selectedBackend !== "basic_text") {
|
||||
return {
|
||||
backend: "safe_storage",
|
||||
payload: safeStorage.encryptString(value),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.allowInsecureVaultFallback()) {
|
||||
throw new Error("Secure secret storage is unavailable. AgentCompany requires OS-backed credential storage unless AGENTCOMPANY_ALLOW_INSECURE_VAULT=true is set for test-only environments.");
|
||||
}
|
||||
|
||||
const iv = randomBytes(12);
|
||||
const key = this.ensureKey();
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return {
|
||||
backend: "vault",
|
||||
payload: Buffer.concat([iv, tag, encrypted]),
|
||||
};
|
||||
}
|
||||
|
||||
decrypt(backend: "safe_storage" | "vault", payload: Buffer): string {
|
||||
if (backend === "safe_storage") {
|
||||
if (!this.canUseSafeStorage()) {
|
||||
throw new Error("safeStorage backend is unavailable in the current runtime.");
|
||||
}
|
||||
return safeStorage.decryptString(payload);
|
||||
}
|
||||
const key = this.ensureKey();
|
||||
const iv = payload.subarray(0, 12);
|
||||
const tag = payload.subarray(12, 28);
|
||||
const encrypted = payload.subarray(28);
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
|
||||
}
|
||||
|
||||
private ensureKey(): Buffer {
|
||||
mkdirSync(this.profileDir, { recursive: true });
|
||||
if (!existsSync(this.keyPath)) {
|
||||
const nextKey = randomBytes(32);
|
||||
writeFileSync(this.keyPath, nextKey);
|
||||
chmodSync(this.keyPath, 0o600);
|
||||
return nextKey;
|
||||
}
|
||||
return readFileSync(this.keyPath);
|
||||
}
|
||||
|
||||
private canUseSafeStorage() {
|
||||
return typeof safeStorage?.isEncryptionAvailable === "function" && safeStorage.isEncryptionAvailable();
|
||||
}
|
||||
|
||||
private allowInsecureVaultFallback() {
|
||||
return (
|
||||
process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT === "true" ||
|
||||
process.env.NODE_ENV === "test" ||
|
||||
process.env.VITEST === "true"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
type DesktopApi,
|
||||
} from "@shared/contracts";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
|
||||
const api: DesktopApi = {
|
||||
loadSnapshot: () => ipcRenderer.invoke(IPC_CHANNELS.loadSnapshot),
|
||||
pickDirectory: () => ipcRenderer.invoke(IPC_CHANNELS.pickDirectory),
|
||||
openPath: (targetPath) => ipcRenderer.invoke(IPC_CHANNELS.openPath, targetPath),
|
||||
saveCompany: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveCompany, input),
|
||||
setCurrentCompany: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.setCurrentCompany, companyId),
|
||||
saveConnector: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveConnector, input),
|
||||
testConnector: (id) => ipcRenderer.invoke(IPC_CHANNELS.testConnector, id),
|
||||
saveWorkspace: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveWorkspace, input),
|
||||
saveAgent: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveAgent, input),
|
||||
saveGoal: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveGoal, input),
|
||||
saveProject: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveProject, input),
|
||||
saveTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveTask, input),
|
||||
requestApproval: (input) => ipcRenderer.invoke(IPC_CHANNELS.requestApproval, input),
|
||||
decideApproval: (input) => ipcRenderer.invoke(IPC_CHANNELS.decideApproval, input),
|
||||
saveSecret: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveSecret, input),
|
||||
startTaskRun: (input) => ipcRenderer.invoke(IPC_CHANNELS.startTaskRun, input),
|
||||
cancelRun: (input) => ipcRenderer.invoke(IPC_CHANNELS.cancelRun, input),
|
||||
getRunLog: (input) => ipcRenderer.invoke(IPC_CHANNELS.getRunLog, input),
|
||||
backupProfile: () => ipcRenderer.invoke(IPC_CHANNELS.backupProfile),
|
||||
restoreProfile: () => ipcRenderer.invoke(IPC_CHANNELS.restoreProfile),
|
||||
updateSettings: (input) => ipcRenderer.invoke(IPC_CHANNELS.updateSettings, input),
|
||||
checkForUpdates: () => ipcRenderer.invoke(IPC_CHANNELS.checkForUpdates),
|
||||
deleteCompany: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.deleteCompany, companyId),
|
||||
deleteAgent: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteAgent, input),
|
||||
deleteTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteTask, input),
|
||||
deleteGoal: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteGoal, input),
|
||||
deleteProject: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteProject, input),
|
||||
deleteWorkspace: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteWorkspace, input),
|
||||
deleteApproval: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteApproval, input),
|
||||
deleteSecret: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSecret, input),
|
||||
getSidebarBadges: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getSidebarBadges, companyId),
|
||||
listConnectorModels: (connectorId) => ipcRenderer.invoke(IPC_CHANNELS.listConnectorModels, connectorId),
|
||||
addComment: (input) => ipcRenderer.invoke(IPC_CHANNELS.addComment, input),
|
||||
listComments: (input) => ipcRenderer.invoke(IPC_CHANNELS.listComments, input),
|
||||
setHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.setHeartbeat, input),
|
||||
triggerHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.triggerHeartbeat, input),
|
||||
getApiPort: () => ipcRenderer.invoke(IPC_CHANNELS.getApiPort),
|
||||
subscribe: (callback: (event: DesktopEvent) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload);
|
||||
ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.rendererEvent, listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("agentCompany", api);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,178 @@
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Buildings, CheckCircle, FolderOpen, WarningCircle, XCircle } from "@phosphor-icons/react";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import { unwrap } from "../lib/desktop";
|
||||
import { ActionButton, Input } from "./ui";
|
||||
|
||||
export function CompanyOnboarding({
|
||||
connectors,
|
||||
onDone,
|
||||
}: {
|
||||
connectors: ProfileSnapshot["connectors"];
|
||||
onDone: () => Promise<void>;
|
||||
}) {
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [workspacePath, setWorkspacePath] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const hasReadyConnector = connectors.some((c) => c.status === "ready" || c.status === "degraded");
|
||||
|
||||
async function pickDirectory() {
|
||||
setError(null);
|
||||
const result = await window.agentCompany.pickDirectory();
|
||||
if (!result.ok) {
|
||||
setError(result.error.message);
|
||||
return;
|
||||
}
|
||||
if (result.data) {
|
||||
setWorkspacePath(result.data);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (loading) return;
|
||||
if (!companyName.trim()) {
|
||||
setError("Enter a company name.");
|
||||
return;
|
||||
}
|
||||
if (!hasReadyConnector) {
|
||||
setError("At least one connector must be detected before creating a company.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const companyId = unwrap(
|
||||
await window.agentCompany.saveCompany({
|
||||
name: companyName.trim(),
|
||||
description: "",
|
||||
status: "active",
|
||||
}),
|
||||
);
|
||||
if (workspacePath) {
|
||||
unwrap(
|
||||
await window.agentCompany.saveWorkspace({
|
||||
companyId,
|
||||
name: "Default Workspace",
|
||||
localPath: workspacePath,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
unwrap(await window.agentCompany.setCurrentCompany(companyId));
|
||||
await onDone();
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "Unable to create the company.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[100dvh] grid-cols-1 bg-[color:var(--bg)] md:grid-cols-[minmax(0,1fr)_440px]">
|
||||
<div className="flex items-center px-8 py-12 md:px-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="max-w-[420px]"
|
||||
>
|
||||
<div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">AgentCompany Desktop</div>
|
||||
<h1 className="text-[38px] leading-[0.98] tracking-[-0.03em] text-[color:var(--text)] md:text-[48px]">
|
||||
Welcome.
|
||||
</h1>
|
||||
<p className="mt-4 text-[14px] leading-relaxed text-[color:var(--muted)]">
|
||||
We detected the following coding agents on your system. You'll configure your organization structure, goals, and agents from the dashboard after setup.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Detected connectors</div>
|
||||
<div className="space-y-2.5">
|
||||
{connectors.map((c) => {
|
||||
const isOk = c.status === "ready" || c.status === "degraded";
|
||||
const isFail = c.status === "not_installed";
|
||||
return (
|
||||
<div key={c.id} className="flex items-center gap-2.5 text-[13px]">
|
||||
{isOk ? (
|
||||
<CheckCircle size={16} weight="fill" className="shrink-0 text-[color:var(--success)]" />
|
||||
) : isFail ? (
|
||||
<XCircle size={16} weight="fill" className="shrink-0 text-[color:var(--muted)]" />
|
||||
) : (
|
||||
<WarningCircle size={16} weight="fill" className="shrink-0 text-[color:var(--warn)]" />
|
||||
)}
|
||||
<span className={isOk ? "font-medium text-[color:var(--text)]" : "text-[color:var(--muted)]"}>
|
||||
{c.label}
|
||||
</span>
|
||||
<span className="text-[11px] text-[color:var(--muted)]">
|
||||
{c.status.replaceAll("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!hasReadyConnector && (
|
||||
<div className="mt-4 rounded-[10px] border border-[color:var(--warn)] bg-[color:var(--warn-soft)] px-4 py-3 text-[12px] text-[color:var(--warn)]">
|
||||
No connector detected. Install at least one supported CLI tool (Claude Code, Codex, Gemini CLI, OpenCode, or Kimi Code) and restart.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center border-l border-[color:var(--line)] bg-[color:var(--bg-subtle)] px-6 py-10 md:px-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.08, duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="surface w-full rounded-[12px] p-7"
|
||||
>
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Getting started</div>
|
||||
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]">Create your company</div>
|
||||
<div className="mb-6 h-px bg-[color:var(--line)]" />
|
||||
<div className="space-y-4">
|
||||
<Input label="Company name" value={companyName} onChange={setCompanyName} placeholder="e.g. Acme Engineering" />
|
||||
<div>
|
||||
<label
|
||||
htmlFor="onboarding-workspace-path"
|
||||
className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]"
|
||||
>
|
||||
Workspace folder <span className="font-normal text-[color:var(--muted)]">(optional)</span>
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
id="onboarding-workspace-path"
|
||||
ariaLabel="Workspace folder"
|
||||
value={workspacePath}
|
||||
onChange={setWorkspacePath}
|
||||
placeholder="/Users/you/project"
|
||||
/>
|
||||
<ActionButton label="Choose" onClick={() => void pickDirectory()} icon={FolderOpen} />
|
||||
</div>
|
||||
<div className="mt-1.5 text-[11px] text-[color:var(--muted)]">
|
||||
You can add more workspaces later from the dashboard.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="mt-5 rounded-[10px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3 text-[13px] text-[color:var(--danger)]" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-8 flex items-center justify-end gap-4">
|
||||
<ActionButton
|
||||
label={loading ? "Creating..." : "Continue to dashboard"}
|
||||
onClick={() => void submit()}
|
||||
icon={Buildings}
|
||||
tone="accent"
|
||||
loading={loading}
|
||||
disabled={!hasReadyConnector || !companyName.trim()}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function EntityWorkspace({
|
||||
title,
|
||||
eyebrow,
|
||||
primary,
|
||||
secondary,
|
||||
}: {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
primary: ReactNode;
|
||||
secondary: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-14">
|
||||
<div>
|
||||
{eyebrow ? <div className="text-[10px] uppercase tracking-[0.08em] text-[color:var(--muted)]">{eyebrow}</div> : null}
|
||||
<h2 className={`${eyebrow ? "mt-2" : ""} text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]`}>{title}</h2>
|
||||
</div>
|
||||
<div className="space-y-14">{primary}{secondary}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { CheckCircle } from "@phosphor-icons/react";
|
||||
import type { ConnectorRecord } from "@shared/types";
|
||||
import { ActionButton, Input, StatusPill, TextArea } from "./ui";
|
||||
|
||||
export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRecord; onSaved: () => Promise<void> }) {
|
||||
const [model, setModel] = useState(connector.model ?? "");
|
||||
const [envBindingText, setEnvBindingText] = useState(connector.envBindingText);
|
||||
const [notes, setNotes] = useState(connector.notes);
|
||||
|
||||
useEffect(() => {
|
||||
setModel(connector.model ?? "");
|
||||
setEnvBindingText(connector.envBindingText);
|
||||
setNotes(connector.notes);
|
||||
}, [connector]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-[color:var(--muted-strong)]">Status</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusPill status={connector.status} />
|
||||
<span className="text-[12px] text-[color:var(--muted)]">{connector.version ?? "version unknown"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-[color:var(--muted-strong)]">Capability matrix</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(connector.capabilityMatrix).map(([key, value]) => (
|
||||
<div key={key} className={`rounded-[6px] border px-3 py-2 text-[12px] ${value ? "border-[color:var(--accent)] border-opacity-20 bg-[color:var(--accent-soft)] text-[color:var(--accent)]" : "border-[color:var(--line)] bg-[color:var(--panel-soft)] text-[color:var(--muted)]"}`}>
|
||||
{key}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Input label="Command" value={connector.command} onChange={() => undefined} readOnly />
|
||||
<div className="mt-1.5 text-[12px] leading-relaxed text-[color:var(--muted)]">
|
||||
AgentCompany pins connector execution to the audited binary for this runtime. Command overrides are not accepted from the renderer.
|
||||
</div>
|
||||
</div>
|
||||
<Input label="Model" value={model} onChange={setModel} placeholder="Leave empty to use connector default" />
|
||||
<TextArea label="Secret bindings" value={envBindingText} onChange={setEnvBindingText} rows={4} placeholder={"OPENAI_API_KEY=my-vault-key\nANTHROPIC_API_KEY=my-anthropic-key"} />
|
||||
<TextArea label="Operational notes" value={notes} onChange={setNotes} rows={4} />
|
||||
<div className="grid gap-3 md:grid-cols-[auto_1fr]">
|
||||
<ActionButton
|
||||
label="Save connector"
|
||||
tone="accent"
|
||||
onClick={() => void (async () => {
|
||||
await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: model || null,
|
||||
envBindingText,
|
||||
notes,
|
||||
});
|
||||
await onSaved();
|
||||
})()}
|
||||
icon={CheckCircle}
|
||||
/>
|
||||
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-4 text-[12px] leading-relaxed text-[color:var(--muted)]">
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Configuration doc</div>
|
||||
<pre className="mono whitespace-pre-wrap">{connector.configurationDoc}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,488 @@
|
||||
import { Component, useEffect, useRef, type ElementType, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
export function Badge({ count, tone = "default" }: { count: number; tone?: "default" | "warn" | "danger" }) {
|
||||
if (count <= 0) return null;
|
||||
const toneClass = tone === "danger" ? "bg-[color:var(--danger)] text-white" : tone === "warn" ? "bg-[color:var(--warn)] text-white" : "bg-[color:var(--muted)] text-white";
|
||||
return (
|
||||
<span className={`ml-auto inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1.5 text-[10px] font-bold ${toneClass}`}>
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionButton({
|
||||
active,
|
||||
label,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
badge,
|
||||
badgeTone,
|
||||
collapsed = false,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
icon: ElementType;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
badgeTone?: "default" | "warn" | "danger";
|
||||
collapsed?: boolean;
|
||||
}) {
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`focus-ring relative flex w-full items-center justify-center rounded-[6px] p-2 transition duration-150 ${
|
||||
active
|
||||
? "bg-[color:var(--panel-soft)] text-[color:var(--text)]"
|
||||
: "text-[color:var(--muted-strong)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon size={18} weight={active ? "fill" : "regular"} className={active ? "text-[color:var(--accent)]" : ""} />
|
||||
{badge ? (
|
||||
<span className={`absolute right-1 top-1 h-[6px] w-[6px] rounded-full ${
|
||||
badgeTone === "danger" ? "bg-[color:var(--danger)]" : badgeTone === "warn" ? "bg-[color:var(--warn)]" : "bg-[color:var(--muted)]"
|
||||
}`} />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`focus-ring flex w-full items-center gap-2.5 rounded-[6px] px-3 py-[7px] text-left transition duration-150 ${
|
||||
active
|
||||
? "bg-[color:var(--panel-soft)] text-[color:var(--text)]"
|
||||
: "text-[color:var(--muted-strong)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon size={17} weight={active ? "fill" : "regular"} className={active ? "text-[color:var(--accent)]" : ""} />
|
||||
<span className={`text-[13px] tracking-[0.005em] ${active ? "font-semibold" : "font-medium"}`}>{label}</span>
|
||||
{badge ? <Badge count={badge} tone={badgeTone} /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Panel({
|
||||
title,
|
||||
eyebrow,
|
||||
children,
|
||||
action,
|
||||
className = "",
|
||||
tone = "plain",
|
||||
}: {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
children: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
tone?: "plain" | "surface";
|
||||
}) {
|
||||
return (
|
||||
<section className={`space-y-4 ${className}`}>
|
||||
<div className={`flex items-end justify-between gap-4 ${tone === "surface" ? "border-b border-[color:var(--line)] pb-3" : ""}`}>
|
||||
<div>
|
||||
{eyebrow ? <div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{eyebrow}</div> : null}
|
||||
<h2 className="text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]">{title}</h2>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
width = "640px",
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
width?: string;
|
||||
}) {
|
||||
const trapRef = useFocusTrap(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--text)]/45" onClick={onClose}>
|
||||
<div
|
||||
ref={trapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="flex w-full flex-col rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] shadow-xl"
|
||||
style={{ maxWidth: width, maxHeight: "80vh" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[color:var(--line)] px-6 py-4">
|
||||
<h2 className="text-[16px] font-semibold text-[color:var(--text)]">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring rounded-[6px] p-1 text-[color:var(--muted)] transition hover:text-[color:var(--text)]"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-6 py-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 text-[12px] font-medium text-[color:var(--muted-strong)]">{label}</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
readOnly = false,
|
||||
disabled = false,
|
||||
label,
|
||||
id,
|
||||
ariaLabel,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
id?: string;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const input = (
|
||||
<input
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
type={type}
|
||||
className="focus-ring w-full rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-3.5 py-2.5 text-[14px] text-[color:var(--text)] placeholder:text-[color:var(--muted)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
if (label) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 text-[12px] font-medium text-[color:var(--muted-strong)]">{label}</div>
|
||||
{input}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function TextArea({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 4,
|
||||
label,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
label?: string;
|
||||
}) {
|
||||
const textarea = (
|
||||
<textarea
|
||||
className="focus-ring w-full rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-3.5 py-2.5 text-[14px] text-[color:var(--text)] placeholder:text-[color:var(--muted)]"
|
||||
value={value}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
if (label) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 text-[12px] font-medium text-[color:var(--muted-strong)]">{label}</div>
|
||||
{textarea}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return textarea;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
compact = false,
|
||||
label,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
compact?: boolean;
|
||||
label?: string;
|
||||
}) {
|
||||
const select = (
|
||||
<select
|
||||
className={`focus-ring w-full border bg-[color:var(--panel)] text-[color:var(--text)] ${
|
||||
compact
|
||||
? "rounded-[6px] border-[color:var(--line)] px-2.5 py-1.5 text-[13px] font-medium"
|
||||
: "rounded-[8px] border-[color:var(--line)] px-3.5 py-2.5 text-[14px]"
|
||||
}`}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-[color:var(--panel)] text-[color:var(--text)]">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
if (label) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 text-[12px] font-medium text-[color:var(--muted-strong)]">{label}</div>
|
||||
{select}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return select;
|
||||
}
|
||||
|
||||
export function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
icon: Icon,
|
||||
tone = "default",
|
||||
loading = false,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: ElementType;
|
||||
tone?: "default" | "accent" | "danger";
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const isDisabled = loading || disabled;
|
||||
const toneClass =
|
||||
tone === "accent"
|
||||
? "border-transparent bg-[color:var(--accent)] text-white hover:brightness-90"
|
||||
: tone === "danger"
|
||||
? "border-transparent bg-[color:var(--danger)] text-white hover:brightness-90"
|
||||
: "border-[color:var(--line)] bg-[color:var(--panel)] text-[color:var(--text)] hover:bg-[color:var(--panel-soft)]";
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`focus-ring inline-flex items-center gap-2 rounded-[7px] border px-3.5 py-2 text-[12px] font-semibold transition duration-150 active:translate-y-px ${toneClass} ${isDisabled ? "pointer-events-none opacity-60" : ""}`}
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
aria-busy={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" opacity="0.3" />
|
||||
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
) : Icon ? (
|
||||
<Icon size={16} />
|
||||
) : null}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
const positive = ["ready", "active", "running", "succeeded", "approved", "achieved", "done"].includes(status);
|
||||
const negative = ["error", "failed", "cancelled", "blocked", "rejected", "interrupted"].includes(status);
|
||||
const warning = ["auth_required", "degraded", "pending", "in_review", "todo", "queued"].includes(status);
|
||||
const checking = status === "detected" || status === "not_installed";
|
||||
const toneClass = positive
|
||||
? "text-[color:var(--success)]"
|
||||
: negative
|
||||
? "text-[color:var(--danger)]"
|
||||
: warning
|
||||
? "text-[color:var(--warn)]"
|
||||
: "text-[color:var(--muted-strong)]";
|
||||
const dotClass = positive
|
||||
? "bg-[color:var(--success)]"
|
||||
: negative
|
||||
? "bg-[color:var(--danger)]"
|
||||
: warning
|
||||
? "bg-[color:var(--warn)]"
|
||||
: "bg-[color:var(--muted)]";
|
||||
|
||||
const displayLabel = checking ? "checking" : status.replaceAll("_", " ");
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] ${toneClass}`}>
|
||||
{checking ? (
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-[color:var(--muted)]" aria-hidden="true" />
|
||||
) : (
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${dotClass}`} aria-hidden="true" />
|
||||
)}
|
||||
{displayLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
detail,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
detail?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-1 py-8 text-center">
|
||||
<div className="mb-2 text-[15px] font-semibold text-[color:var(--text)]">{title}</div>
|
||||
{detail ? <div className="mx-auto max-w-[48ch] text-[13px] leading-relaxed text-[color:var(--muted)]">{detail}</div> : null}
|
||||
{action ? <div className="mt-5">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListFrame({
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={`border-y border-[color:var(--line)] ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
export function ListRow({
|
||||
children,
|
||||
className = "",
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`focus-ring block w-full border-b border-[color:var(--line)] px-5 py-4 text-left last:border-b-0 ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <div className={`border-b border-[color:var(--line)] px-5 py-4 last:border-b-0 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
/* ── Focus Trap Hook ────────────────────────────────────────────────── */
|
||||
|
||||
export function useFocusTrap(active: boolean) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !ref.current) return;
|
||||
|
||||
const container = ref.current;
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
|
||||
// Focus first focusable element
|
||||
const focusable = container.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
focusable[0]?.focus();
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key !== "Tab") return;
|
||||
const focusableNow = container.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (focusableNow.length === 0) return;
|
||||
|
||||
const first = focusableNow[0];
|
||||
const last = focusableNow[focusableNow.length - 1];
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
container.removeEventListener("keydown", handleKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
/* ── Reduced Motion Hook ────────────────────────────────────────────── */
|
||||
|
||||
export function useReducedMotion() {
|
||||
const query = typeof window !== "undefined" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
|
||||
// Return static value — Electron doesn't change this mid-session
|
||||
return query?.matches ?? false;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<{ children: ReactNode; fallback?: ReactNode }, { hasError: boolean; error: Error | null }> {
|
||||
constructor(props: { children: ReactNode; fallback?: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("[ErrorBoundary]", error, info);
|
||||
}
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback ?? (
|
||||
<div className="p-8 text-center" role="alert">
|
||||
<div className="text-[15px] font-semibold text-[color:var(--danger)] mb-2">Something went wrong</div>
|
||||
<div className="text-[13px] text-[color:var(--muted)] mb-4">{this.state.error?.message}</div>
|
||||
<ActionButton label="Reload page" onClick={() => window.location.reload()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import type { DesktopApi } from "@shared/contracts";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
agentCompany: DesktopApi;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self';"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AgentCompany</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
|
||||
export function unwrap<T>(result: DesktopResult<T>): T {
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function formatMoney(value: number | null | undefined) {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) return "$0.00";
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatTime(value: string | null | undefined) {
|
||||
if (!value) return "Not yet";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import "@fontsource-variable/jetbrains-mono";
|
||||
import "./styles.css";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,156 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #fbfbfb;
|
||||
--bg-subtle: #ffffff;
|
||||
--panel: #ffffff;
|
||||
--panel-soft: #f4f5f5;
|
||||
--panel-strong: #fafafa;
|
||||
--line: rgba(15, 23, 42, 0.08);
|
||||
--line-strong: rgba(15, 23, 42, 0.14);
|
||||
--text: #1a202c;
|
||||
--muted: #718096;
|
||||
--muted-strong: #4a5568;
|
||||
--accent: #c06a3a;
|
||||
--accent-soft: #f7ece6;
|
||||
--danger: #e5484d;
|
||||
--danger-soft: #fdebec;
|
||||
--warn: #b7791f;
|
||||
--warn-soft: #fdf6e3;
|
||||
--success: #2f855a;
|
||||
--success-soft: #edf7f0;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #111315;
|
||||
--bg-subtle: #171a1d;
|
||||
--panel: #1b1f23;
|
||||
--panel-soft: #232931;
|
||||
--panel-strong: #20252c;
|
||||
--line: rgba(226, 232, 240, 0.1);
|
||||
--line-strong: rgba(226, 232, 240, 0.18);
|
||||
--text: #f4f7fb;
|
||||
--muted: #9aa7b5;
|
||||
--muted-strong: #c2ccd8;
|
||||
--accent: #ee8a58;
|
||||
--accent-soft: rgba(238, 138, 88, 0.14);
|
||||
--danger: #ff7b86;
|
||||
--danger-soft: rgba(255, 123, 134, 0.14);
|
||||
--warn: #f4bc5b;
|
||||
--warn-soft: rgba(244, 188, 91, 0.14);
|
||||
--success: #61d69d;
|
||||
--success-soft: rgba(97, 214, 157, 0.14);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[data-theme="system"] {
|
||||
color-scheme: dark;
|
||||
--bg: #111315;
|
||||
--bg-subtle: #171a1d;
|
||||
--panel: #1b1f23;
|
||||
--panel-soft: #232931;
|
||||
--panel-strong: #20252c;
|
||||
--line: rgba(226, 232, 240, 0.1);
|
||||
--line-strong: rgba(226, 232, 240, 0.18);
|
||||
--text: #f4f7fb;
|
||||
--muted: #9aa7b5;
|
||||
--muted-strong: #c2ccd8;
|
||||
--accent: #ee8a58;
|
||||
--accent-soft: rgba(238, 138, 88, 0.14);
|
||||
--danger: #ff7b86;
|
||||
--danger-soft: rgba(255, 123, 134, 0.14);
|
||||
--warn: #f4bc5b;
|
||||
--warn-soft: rgba(244, 188, 91, 0.14);
|
||||
--success: #61d69d;
|
||||
--success-soft: rgba(97, 214, 157, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
.sidebar-drag {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.sidebar-drag button,
|
||||
.sidebar-drag select,
|
||||
.sidebar-drag input,
|
||||
.sidebar-drag a {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgba(113, 128, 150, 0.3);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "JetBrains Mono Variable", monospace;
|
||||
}
|
||||
|
||||
.surface {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.focus-ring {
|
||||
outline: none;
|
||||
transition:
|
||||
box-shadow 180ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 180ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 120ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.focus-ring:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(192, 106, 58, 0.5),
|
||||
0 0 0 4px rgba(192, 106, 58, 0.12);
|
||||
border-color: rgba(192, 106, 58, 0.35);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { z } from "zod";
|
||||
import type { CommentRecord, DesktopEvent, ProfileSnapshot } from "./types";
|
||||
|
||||
export const IPC_CHANNELS = {
|
||||
loadSnapshot: "agent-company:load-snapshot",
|
||||
pickDirectory: "agent-company:pick-directory",
|
||||
openPath: "agent-company:open-path",
|
||||
saveCompany: "agent-company:save-company",
|
||||
setCurrentCompany: "agent-company:set-current-company",
|
||||
saveConnector: "agent-company:save-connector",
|
||||
testConnector: "agent-company:test-connector",
|
||||
saveWorkspace: "agent-company:save-workspace",
|
||||
saveAgent: "agent-company:save-agent",
|
||||
saveGoal: "agent-company:save-goal",
|
||||
saveProject: "agent-company:save-project",
|
||||
saveTask: "agent-company:save-task",
|
||||
requestApproval: "agent-company:request-approval",
|
||||
decideApproval: "agent-company:decide-approval",
|
||||
saveSecret: "agent-company:save-secret",
|
||||
startTaskRun: "agent-company:start-task-run",
|
||||
cancelRun: "agent-company:cancel-run",
|
||||
getRunLog: "agent-company:get-run-log",
|
||||
backupProfile: "agent-company:backup-profile",
|
||||
restoreProfile: "agent-company:restore-profile",
|
||||
updateSettings: "agent-company:update-settings",
|
||||
checkForUpdates: "agent-company:check-for-updates",
|
||||
deleteCompany: "agent-company:delete-company",
|
||||
deleteAgent: "agent-company:delete-agent",
|
||||
deleteTask: "agent-company:delete-task",
|
||||
deleteGoal: "agent-company:delete-goal",
|
||||
deleteProject: "agent-company:delete-project",
|
||||
deleteWorkspace: "agent-company:delete-workspace",
|
||||
deleteApproval: "agent-company:delete-approval",
|
||||
deleteSecret: "agent-company:delete-secret",
|
||||
getSidebarBadges: "agent-company:get-sidebar-badges",
|
||||
listConnectorModels: "agent-company:list-connector-models",
|
||||
addComment: "agent-company:add-comment",
|
||||
listComments: "agent-company:list-comments",
|
||||
setHeartbeat: "agent-company:set-heartbeat",
|
||||
triggerHeartbeat: "agent-company:trigger-heartbeat",
|
||||
getApiPort: "agent-company:get-api-port",
|
||||
rendererEvent: "agent-company:event",
|
||||
} as const;
|
||||
|
||||
const optionalId = z.preprocess(
|
||||
(value) => (value === "" ? null : value),
|
||||
z.string().uuid().nullable().optional(),
|
||||
);
|
||||
|
||||
export const companyInputSchema = z.object({
|
||||
id: optionalId,
|
||||
name: z.string().min(2).max(80),
|
||||
description: z.string().max(500).default(""),
|
||||
status: z.enum(["active", "paused", "archived"]).default("active"),
|
||||
});
|
||||
|
||||
export const connectorInputSchema = z.object({
|
||||
id: z.enum(["codex_local", "claude_local", "gemini_local", "opencode_local", "kimi_local"]),
|
||||
command: z.string().min(1),
|
||||
model: z.string().nullable().optional(),
|
||||
envBindingText: z.string().default(""),
|
||||
notes: z.string().default(""),
|
||||
});
|
||||
|
||||
export const workspaceInputSchema = z.object({
|
||||
id: optionalId,
|
||||
companyId: z.string().uuid(),
|
||||
name: z.string().min(2).max(80),
|
||||
localPath: z.string().min(1),
|
||||
repoUrl: z.string().default(""),
|
||||
repoRef: z.string().default(""),
|
||||
projectId: optionalId,
|
||||
isPrimary: z.boolean().default(false),
|
||||
});
|
||||
|
||||
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(""),
|
||||
reportsTo: optionalId,
|
||||
connectorId: connectorInputSchema.shape.id,
|
||||
workspaceId: optionalId,
|
||||
model: z.string().nullable().optional(),
|
||||
metadataJson: z.string().default("{}").optional(),
|
||||
capabilities: z.string().default(""),
|
||||
budgetMonthlyUsd: z.number().min(0).default(0),
|
||||
status: z.enum(["idle", "active", "running", "paused", "error", "pending_approval", "terminated"]).default("idle"),
|
||||
});
|
||||
|
||||
export const goalInputSchema = z.object({
|
||||
id: optionalId,
|
||||
companyId: z.string().uuid(),
|
||||
title: z.string().min(2).max(120),
|
||||
description: z.string().default(""),
|
||||
parentId: optionalId,
|
||||
ownerAgentId: optionalId,
|
||||
status: z.enum(["planned", "active", "achieved", "cancelled"]).default("planned"),
|
||||
});
|
||||
|
||||
export const projectInputSchema = z.object({
|
||||
id: optionalId,
|
||||
companyId: z.string().uuid(),
|
||||
goalId: optionalId,
|
||||
name: z.string().min(2).max(120),
|
||||
description: z.string().default(""),
|
||||
leadAgentId: optionalId,
|
||||
targetDate: z.string().nullable().optional(),
|
||||
status: z.enum(["backlog", "planned", "in_progress", "completed", "cancelled"]).default("planned"),
|
||||
});
|
||||
|
||||
export const taskInputSchema = z.object({
|
||||
id: optionalId,
|
||||
companyId: z.string().uuid(),
|
||||
projectId: optionalId,
|
||||
goalId: optionalId,
|
||||
parentId: optionalId,
|
||||
title: z.string().min(2).max(160),
|
||||
description: z.string().default(""),
|
||||
assigneeAgentId: optionalId,
|
||||
workspaceId: optionalId,
|
||||
priority: z.enum(["critical", "high", "medium", "low"]).default("medium"),
|
||||
status: z.enum(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]).default("todo"),
|
||||
});
|
||||
|
||||
export const approvalInputSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
relatedTaskId: optionalId,
|
||||
requestedByAgentId: optionalId,
|
||||
type: z.enum(["hire_agent", "approve_ceo_strategy", "dangerous_command", "secret_access"]),
|
||||
payloadSummary: z.string().min(2).max(280),
|
||||
impactSummary: z.string().min(2).max(400),
|
||||
});
|
||||
|
||||
export const approvalDecisionSchema = z.object({
|
||||
approvalId: z.string().uuid(),
|
||||
state: z.enum(["approved", "rejected", "revision_requested"]),
|
||||
decisionNote: z.string().max(400).default(""),
|
||||
});
|
||||
|
||||
export const secretInputSchema = z.object({
|
||||
id: optionalId,
|
||||
companyId: z.string().uuid(),
|
||||
name: z.string().min(2).max(80),
|
||||
description: z.string().default(""),
|
||||
value: z.string().min(1).max(2000),
|
||||
});
|
||||
|
||||
export const startRunSchema = z.object({
|
||||
taskId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const cancelRunSchema = z.object({
|
||||
runId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const runLogSchema = z.object({
|
||||
runId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const deleteEntitySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const sidebarBadgesSchema = z.object({
|
||||
pendingApprovals: z.number(),
|
||||
activeRuns: z.number(),
|
||||
failedRuns: z.number(),
|
||||
todoTasks: z.number(),
|
||||
budgetWarnings: z.number(),
|
||||
});
|
||||
export type SidebarBadges = z.infer<typeof sidebarBadgesSchema>;
|
||||
|
||||
export const settingsSchema = z.object({
|
||||
theme: z.enum(["system", "light", "dark"]).optional(),
|
||||
updatesEnabled: z.boolean().optional(),
|
||||
logRetentionDays: z.number().int().min(1).max(365).optional(),
|
||||
});
|
||||
|
||||
export const commentInputSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
taskId: z.string().uuid(),
|
||||
authorAgentId: optionalId,
|
||||
authorName: z.string().min(1).max(80),
|
||||
body: z.string().min(1).max(4000),
|
||||
});
|
||||
|
||||
export const listCommentsSchema = z.object({
|
||||
taskId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const heartbeatSettingsSchema = z.object({
|
||||
agentId: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
enabled: z.boolean(),
|
||||
intervalSec: z.number().int().min(30).max(86400).default(120),
|
||||
});
|
||||
|
||||
export const triggerHeartbeatSchema = z.object({
|
||||
agentId: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
trigger: z.enum(["manual", "timer", "assignment", "goal_activated", "approval_resolved", "subtask_completed", "comment", "report_blocked", "report_failed", "recovery"]).default("manual"),
|
||||
});
|
||||
|
||||
export type DesktopResult<T> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; error: { code: string; message: string } };
|
||||
|
||||
export type DesktopApi = {
|
||||
loadSnapshot: () => Promise<DesktopResult<ProfileSnapshot>>;
|
||||
pickDirectory: () => Promise<DesktopResult<string | null>>;
|
||||
openPath: (targetPath: string) => Promise<DesktopResult<boolean>>;
|
||||
saveCompany: (input: z.infer<typeof companyInputSchema>) => Promise<DesktopResult<string>>;
|
||||
setCurrentCompany: (companyId: string) => Promise<DesktopResult<boolean>>;
|
||||
saveConnector: (input: z.infer<typeof connectorInputSchema>) => Promise<DesktopResult<boolean>>;
|
||||
testConnector: (id: z.infer<typeof connectorInputSchema.shape.id>) => Promise<DesktopResult<boolean>>;
|
||||
saveWorkspace: (input: z.infer<typeof workspaceInputSchema>) => Promise<DesktopResult<string>>;
|
||||
saveAgent: (input: z.infer<typeof agentInputSchema>) => Promise<DesktopResult<string>>;
|
||||
saveGoal: (input: z.infer<typeof goalInputSchema>) => Promise<DesktopResult<string>>;
|
||||
saveProject: (input: z.infer<typeof projectInputSchema>) => Promise<DesktopResult<string>>;
|
||||
saveTask: (input: z.infer<typeof taskInputSchema>) => Promise<DesktopResult<string>>;
|
||||
requestApproval: (input: z.infer<typeof approvalInputSchema>) => Promise<DesktopResult<string>>;
|
||||
decideApproval: (input: z.infer<typeof approvalDecisionSchema>) => Promise<DesktopResult<boolean>>;
|
||||
saveSecret: (input: z.infer<typeof secretInputSchema>) => Promise<DesktopResult<string>>;
|
||||
startTaskRun: (input: z.infer<typeof startRunSchema>) => Promise<DesktopResult<string>>;
|
||||
cancelRun: (input: z.infer<typeof cancelRunSchema>) => Promise<DesktopResult<boolean>>;
|
||||
getRunLog: (input: z.infer<typeof runLogSchema>) => Promise<DesktopResult<string>>;
|
||||
backupProfile: () => Promise<DesktopResult<string | null>>;
|
||||
restoreProfile: () => Promise<DesktopResult<boolean>>;
|
||||
updateSettings: (input: z.infer<typeof settingsSchema>) => Promise<DesktopResult<boolean>>;
|
||||
checkForUpdates: () => Promise<DesktopResult<boolean>>;
|
||||
deleteCompany: (companyId: string) => Promise<DesktopResult<boolean>>;
|
||||
deleteAgent: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||
deleteTask: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||
deleteGoal: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||
deleteProject: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||
deleteWorkspace: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||
deleteApproval: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||
deleteSecret: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||
getSidebarBadges: (companyId: string) => Promise<DesktopResult<SidebarBadges>>;
|
||||
listConnectorModels: (connectorId: string) => Promise<DesktopResult<Array<{ id: string; label: string }>>>;
|
||||
addComment: (input: z.infer<typeof commentInputSchema>) => Promise<DesktopResult<string>>;
|
||||
listComments: (input: z.infer<typeof listCommentsSchema>) => Promise<DesktopResult<CommentRecord[]>>;
|
||||
setHeartbeat: (input: z.infer<typeof heartbeatSettingsSchema>) => Promise<DesktopResult<boolean>>;
|
||||
triggerHeartbeat: (input: z.infer<typeof triggerHeartbeatSchema>) => Promise<DesktopResult<string>>;
|
||||
getApiPort: () => Promise<DesktopResult<number>>;
|
||||
subscribe: (callback: (event: DesktopEvent) => void) => () => void;
|
||||
};
|
||||
@@ -0,0 +1,321 @@
|
||||
export type SectionId =
|
||||
| "overview"
|
||||
| "connectors"
|
||||
| "agents"
|
||||
| "orgchart"
|
||||
| "goals"
|
||||
| "projects"
|
||||
| "tasks"
|
||||
| "approvals"
|
||||
| "costs"
|
||||
| "activity"
|
||||
| "workspaces"
|
||||
| "settings";
|
||||
|
||||
export type ConnectorId =
|
||||
| "codex_local"
|
||||
| "claude_local"
|
||||
| "gemini_local"
|
||||
| "opencode_local"
|
||||
| "kimi_local";
|
||||
|
||||
export type ConnectorStatus =
|
||||
| "not_installed"
|
||||
| "detected"
|
||||
| "auth_required"
|
||||
| "ready"
|
||||
| "degraded"
|
||||
| "error";
|
||||
|
||||
export type WorkspaceState = "ready" | "busy" | "blocked" | "missing" | "error";
|
||||
export type CompanyStatus = "active" | "paused" | "archived";
|
||||
export type AgentStatus =
|
||||
| "idle"
|
||||
| "active"
|
||||
| "running"
|
||||
| "paused"
|
||||
| "error"
|
||||
| "pending_approval"
|
||||
| "terminated";
|
||||
export type GoalStatus = "planned" | "active" | "achieved" | "cancelled";
|
||||
export type ProjectStatus = "backlog" | "planned" | "in_progress" | "completed" | "cancelled";
|
||||
export type TaskStatus = "backlog" | "todo" | "in_progress" | "in_review" | "done" | "blocked" | "cancelled";
|
||||
export type ApprovalState = "pending" | "approved" | "rejected" | "revision_requested";
|
||||
export type RunStatus =
|
||||
| "queued"
|
||||
| "running"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "timed_out"
|
||||
| "interrupted";
|
||||
|
||||
export interface CapabilityMatrix {
|
||||
sessionResume: boolean;
|
||||
gracefulCancel: boolean;
|
||||
modelDiscovery: boolean;
|
||||
structuredTranscript: boolean;
|
||||
costAttribution: boolean;
|
||||
}
|
||||
|
||||
export interface CompanyRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: CompanyStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ConnectorRecord {
|
||||
id: ConnectorId;
|
||||
label: string;
|
||||
description: string;
|
||||
status: ConnectorStatus;
|
||||
command: string;
|
||||
version: string | null;
|
||||
authState: "unknown" | "ok" | "required" | "error";
|
||||
lastCheckedAt: string | null;
|
||||
lastError: string | null;
|
||||
model: string | null;
|
||||
envBindingText: string;
|
||||
notes: string;
|
||||
configurationDoc: string;
|
||||
capabilityMatrix: CapabilityMatrix;
|
||||
}
|
||||
|
||||
export interface WorkspaceRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
localPath: string;
|
||||
repoUrl: string;
|
||||
repoRef: string;
|
||||
projectId: string | null;
|
||||
state: WorkspaceState;
|
||||
isPrimary: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AgentRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
role: string;
|
||||
title: string;
|
||||
status: AgentStatus;
|
||||
reportsTo: string | null;
|
||||
connectorId: ConnectorId;
|
||||
workspaceId: string | null;
|
||||
model: string | null;
|
||||
capabilities: string;
|
||||
budgetMonthlyUsd: number;
|
||||
spentMonthlyUsd: number;
|
||||
metadataJson: string;
|
||||
heartbeatEnabled: boolean;
|
||||
heartbeatIntervalSec: number;
|
||||
lastHeartbeatAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GoalRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
parentId: string | null;
|
||||
ownerAgentId: string | null;
|
||||
status: GoalStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProjectRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
goalId: string | null;
|
||||
name: string;
|
||||
description: string;
|
||||
leadAgentId: string | null;
|
||||
status: ProjectStatus;
|
||||
targetDate: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TaskRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
goalId: string | null;
|
||||
parentId: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
assigneeAgentId: string | null;
|
||||
workspaceId: string | null;
|
||||
status: TaskStatus;
|
||||
priority: "critical" | "high" | "medium" | "low";
|
||||
activeRunId: string | null;
|
||||
sessionJson: string | null;
|
||||
sessionDisplayId: string | null;
|
||||
costUsd: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ApprovalRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
relatedTaskId: string | null;
|
||||
relatedAgentId: string | null;
|
||||
requestedByAgentId: string | null;
|
||||
type: "hire_agent" | "approve_ceo_strategy" | "dangerous_command" | "secret_access";
|
||||
payloadSummary: string;
|
||||
impactSummary: string;
|
||||
payloadJson: string | null;
|
||||
decisionNote: string;
|
||||
state: ApprovalState;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RunRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
workspaceId: string | null;
|
||||
connectorId: ConnectorId;
|
||||
status: RunStatus;
|
||||
summary: string;
|
||||
errorMessage: string | null;
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
model: string | null;
|
||||
sessionDisplayId: string | null;
|
||||
costUsd: number | null;
|
||||
unattributedCost: boolean;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
logPath: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CostRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
runId: string;
|
||||
taskId: string | null;
|
||||
agentId: string | null;
|
||||
connectorId: ConnectorId;
|
||||
amountUsd: number | null;
|
||||
unattributed: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ActivityRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
actor: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
detail: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SecretRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
backend: "safe_storage" | "vault";
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProfileSnapshot {
|
||||
currentCompanyId: string | null;
|
||||
profilePath: string;
|
||||
updatesEnabled: boolean;
|
||||
theme: "system" | "light" | "dark";
|
||||
backend: {
|
||||
secretBackend: string;
|
||||
};
|
||||
companies: CompanyRecord[];
|
||||
connectors: ConnectorRecord[];
|
||||
workspaces: WorkspaceRecord[];
|
||||
agents: AgentRecord[];
|
||||
goals: GoalRecord[];
|
||||
projects: ProjectRecord[];
|
||||
tasks: TaskRecord[];
|
||||
approvals: ApprovalRecord[];
|
||||
runs: RunRecord[];
|
||||
costs: CostRecord[];
|
||||
activity: ActivityRecord[];
|
||||
secrets: SecretRecord[];
|
||||
comments: CommentRecord[];
|
||||
}
|
||||
|
||||
export interface LogChunkEvent {
|
||||
type: "run-log";
|
||||
runId: string;
|
||||
stream: "stdout" | "stderr";
|
||||
chunk: string;
|
||||
}
|
||||
|
||||
export interface DomainChangedEvent {
|
||||
type: "domain-changed";
|
||||
}
|
||||
|
||||
export interface ShortcutEvent {
|
||||
type: "shortcut";
|
||||
action: "toggle-command-palette" | "toggle-console" | "refresh";
|
||||
}
|
||||
|
||||
export interface RunStatusEvent {
|
||||
type: "run-status";
|
||||
runId: string;
|
||||
status: RunStatus;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type HeartbeatTrigger = "manual" | "timer" | "assignment" | "goal_activated" | "approval_resolved" | "subtask_completed" | "comment" | "report_blocked" | "report_failed" | "recovery";
|
||||
|
||||
export interface CommentRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
taskId: string;
|
||||
authorAgentId: string | null;
|
||||
authorName: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface OrgChartNode {
|
||||
agent: AgentRecord;
|
||||
children: OrgChartNode[];
|
||||
}
|
||||
|
||||
export interface HeartbeatStatusEvent {
|
||||
type: "heartbeat-status";
|
||||
agentId: string;
|
||||
enabled: boolean;
|
||||
nextWakeAt: string | null;
|
||||
}
|
||||
|
||||
export interface AgentMessageEvent {
|
||||
type: "agent-message";
|
||||
taskId: string;
|
||||
comment: CommentRecord;
|
||||
}
|
||||
|
||||
export type DesktopEvent =
|
||||
| LogChunkEvent
|
||||
| DomainChangedEvent
|
||||
| ShortcutEvent
|
||||
| RunStatusEvent
|
||||
| HeartbeatStatusEvent
|
||||
| AgentMessageEvent;
|
||||
Reference in new issue
Block a user