Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Sonnet 4.6 committed 2026-03-30 22:02:28 -04:00
1 parent 16d8426119
commit 66158e222e
398 files changed
+85259 -270637

No files matched your search

+63 -7
View File
@@ -10,6 +10,7 @@ import type {
import { dispatchAgentMessage } from "./message-dispatch";
import { validateTaskCreation } from "./task-quality-gates";
import type { NotificationOptions } from "./notification-service";
import { notifyHireApproved } from "./hire-hook";
/** Document types considered important enough to gate behind user review when reviewDeliverables is enabled. */
const IMPORTANT_DOCUMENT_TYPES = new Set([
@@ -98,6 +99,10 @@ function parseDocumentIdsFromPayload(payloadJson: string | null): string[] {
return [];
}
function localizeNotification(locale: string | null | undefined, en: string, zh: string): string {
return locale === "zh" ? zh : en;
}
function scoreSkillAgentMatch(
skill: { name: string; description: string | null; markdown: string },
agent: { role: string; department: string | null; title: string | null; capabilities: string },
@@ -159,6 +164,21 @@ export function autoAssignSkillsToAgent(
const existingAssignments = new Set(db.listAgentSkillAssignments(agentId));
// Always assign basic-for-all builtin skills regardless of score
let assigned = 0;
for (const skill of allSkills) {
if (existingAssignments.has(skill.id)) continue;
try {
const meta = skill.metadata ? (typeof skill.metadata === "string" ? JSON.parse(skill.metadata) : skill.metadata) : {};
if (meta.builtinCategory === "basic-for-all") {
db.assignSkillToAgent(agentId, skill.id);
existingAssignments.add(skill.id);
assigned++;
logger.info(`[skill-autoassign] Assigned basic skill "${skill.name}" to ${agent.name}`);
}
} catch { /* skip malformed metadata */ }
}
const scored = allSkills
.filter((skill) => !existingAssignments.has(skill.id))
.map((skill) => ({
@@ -168,7 +188,6 @@ export function autoAssignSkillsToAgent(
.sort((a, b) => b.score - a.score);
const MIN_SCORE = 3;
let assigned = 0;
for (const { skill, score } of scored) {
if (score < MIN_SCORE) continue;
try {
@@ -1138,9 +1157,10 @@ export function createAutomationService({
});
// Fire OS notification for budget threshold warning
const pct = Math.round(utilization * 100);
const budgetLocale = db.listSnapshot().locale;
notify({
title: "Budget Warning",
body: `${agent.name} has used ${pct}% of monthly budget`,
title: localizeNotification(budgetLocale, "Budget Warning", "预算警告"),
body: localizeNotification(budgetLocale, `${agent.name} has used ${pct}% of monthly budget`, `${agent.name} 已使用月度预算的 ${pct}%`),
urgency: "informational",
navigation: { section: "costs" },
batchKey: "budget_alerts",
@@ -1148,8 +1168,8 @@ export function createAutomationService({
if (utilization >= 1.0) {
// Fire OS notification for budget exhaustion (agent auto-paused by database)
notify({
title: "Budget Exhausted",
body: `${agent.name} has reached 100% of monthly budget and is paused`,
title: localizeNotification(budgetLocale, "Budget Exhausted", "预算耗尽"),
body: localizeNotification(budgetLocale, `${agent.name} has reached 100% of monthly budget and is paused`, `${agent.name} 已达到月度预算上限并已暂停`),
urgency: "critical",
navigation: { section: "costs" },
});
@@ -1211,13 +1231,23 @@ export function createAutomationService({
}
publishDomainChanged();
const hireLocale = db.listSnapshot().locale;
notify({
title: "Agent Hired",
body: `${agent.name} joined as ${agent.role}`,
title: localizeNotification(hireLocale, "Agent Hired", "新员工入职"),
body: localizeNotification(hireLocale, `${agent.name} joined as ${agent.role}`, `${agent.name} 已加入,职位:${agent.role}`),
urgency: "informational",
navigation: { section: "orgchart", entityId: agent.id },
batchKey: "agent_hired",
});
// Notify adapter that hire was approved (Paperclip hire-hook parity)
void notifyHireApproved(db, {
companyId,
agentId: agent.id,
source: "approval",
sourceId: approvalId,
approvedAt: new Date().toISOString(),
}).catch(() => {});
}
} catch {
// Hire follow-up is best-effort; approval state is already committed in storage.
@@ -1279,6 +1309,32 @@ export function createAutomationService({
}
}
// Handle budget override approvals
if (approval.type === "budget_override" && decision === "approved") {
try {
const payload = JSON.parse(approval.payloadJson ?? "{}") as {
policyId?: string;
scopeType?: string;
scopeId?: string;
newAmount?: number;
};
if (payload.policyId) {
// Resolve the budget incident that triggered this approval
db.resolveBudgetIncident(payload.policyId, companyId);
db.addActivity({
companyId,
actor: "board",
action: "budget.override_approved",
entityType: "approval",
entityId: approvalId,
detail: `Budget override approved for ${payload.scopeType} scope`,
});
}
} catch (err) {
logger.error(`[automation] Failed to process budget_override approval: ${err instanceof Error ? err.message : String(err)}`);
}
}
const browserAction = db.getBrowserActionByApprovalId(approvalId, companyId);
if (browserAction) {
if (decision === "approved") {
+15
View File
@@ -67,6 +67,21 @@ export async function bootstrapApplication({
}: BootstrapApplicationDependencies) {
await app.whenReady();
log.initialize();
// Configure log format for cleaner output
log.transports.console.format = "[{h}:{i}:{s}] [{level}] {text}";
log.transports.file.format = "[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}";
// In production, only show warnings and errors in console
// File logging always captures info+
if (app.isPackaged) {
log.transports.console.level = "warn";
log.transports.file.level = "info";
} else {
log.transports.console.level = "info";
log.transports.file.level = "debug";
}
await prepareHostEnvironment({ logger: log });
const db = appDatabaseFactory(profileDir);
+307
View File
@@ -814,6 +814,10 @@ export class AppDatabase {
}
const existing = this.db.prepare("select id from agents where id = ? and company_id = ?").get(id, input.companyId) as { id: string } | undefined;
if (existing) {
// Snapshot before-config for revision tracking
const beforeAgent = this.db.prepare(
"SELECT name, role, title, department, capabilities, connector_id, model, budget_monthly_usd, metadata_json FROM agents WHERE id = ? AND company_id = ?"
).get(id, input.companyId) as Record<string, any> | undefined;
this.db
.prepare(
"update agents set name = ?, role = ?, title = ?, department = ?, status = ?, reports_to = ?, connector_id = ?, workspace_id = ?, model = ?, metadata_json = ?, capabilities = ?, budget_monthly_usd = ?, updated_at = ? where id = ? and company_id = ?",
@@ -835,6 +839,17 @@ export class AppDatabase {
id,
input.companyId,
);
// Record config revision if fields changed
if (beforeAgent) {
const afterConfig = { name: input.name, role: input.role, title: input.title, department, capabilities: input.capabilities, connectorId: input.connectorId, model: normalizeOptionalId(input.model), budgetMonthlyUsd: input.budgetMonthlyUsd, metadataJson };
const beforeConfig = { name: beforeAgent.name, role: beforeAgent.role, title: beforeAgent.title, department: beforeAgent.department, capabilities: beforeAgent.capabilities, connectorId: beforeAgent.connector_id, model: beforeAgent.model, budgetMonthlyUsd: beforeAgent.budget_monthly_usd, metadataJson: beforeAgent.metadata_json };
const changedKeys = Object.keys(afterConfig).filter(k => JSON.stringify((afterConfig as any)[k]) !== JSON.stringify((beforeConfig as any)[k]));
if (changedKeys.length > 0) {
try {
this.recordConfigRevision({ companyId: input.companyId, agentId: id, changedKeys, configBefore: beforeConfig, configAfter: afterConfig });
} catch { /* Non-fatal: revision recording should never block agent save */ }
}
}
} else {
this.db
.prepare(
@@ -4346,6 +4361,55 @@ export class AppDatabase {
try { this.db.prepare("ALTER TABLE wakeup_requests ADD COLUMN context_snapshot TEXT").run(); } catch { /* already exists */ }
},
},
{
version: 22,
name: "add_config_revisions_run_events_agent_permissions",
fn: () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS agent_config_revisions (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
revision_number INTEGER NOT NULL,
changed_keys TEXT NOT NULL DEFAULT '[]',
config_before TEXT NOT NULL,
config_after TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'patch',
rolled_back_from_revision_id TEXT,
created_by_agent_id TEXT,
created_by_user_id TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_config_rev_num ON agent_config_revisions(agent_id, revision_number);
CREATE INDEX IF NOT EXISTS idx_agent_config_rev_agent ON agent_config_revisions(company_id, agent_id, created_at);
CREATE TABLE IF NOT EXISTS run_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id TEXT NOT NULL,
run_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
seq INTEGER NOT NULL,
event_type TEXT NOT NULL,
stream TEXT,
level TEXT,
message TEXT,
payload TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_run_events_run ON run_events(run_id, seq);
CREATE INDEX IF NOT EXISTS idx_run_events_company ON run_events(company_id, run_id);
`);
const addCol = (sql: string) => { try { this.db.prepare(sql).run(); } catch { /* already exists */ } };
addCol("ALTER TABLE agents ADD COLUMN permissions_json TEXT NOT NULL DEFAULT '{}'");
addCol("ALTER TABLE agents ADD COLUMN heartbeat_policy_json TEXT");
addCol("ALTER TABLE agents ADD COLUMN runtime_state_json TEXT");
addCol("ALTER TABLE runs ADD COLUMN context_snapshot_json TEXT");
addCol("ALTER TABLE runs ADD COLUMN actor_type TEXT");
addCol("ALTER TABLE runs ADD COLUMN actor_id TEXT");
},
},
];
for (const migration of migrations) {
@@ -5133,6 +5197,249 @@ export class AppDatabase {
return Number(result.changes);
}
recordConfigRevision(input: {
companyId: string;
agentId: string;
changedKeys: string[];
configBefore: Record<string, unknown>;
configAfter: Record<string, unknown>;
source?: string;
rolledBackFromRevisionId?: string | null;
createdByAgentId?: string | null;
createdByUserId?: string | null;
}): string {
const id = randomUUID();
const ts = nowIso();
const lastRev = this.db.prepare(
"SELECT MAX(revision_number) as maxRev FROM agent_config_revisions WHERE agent_id = ?"
).get(input.agentId) as { maxRev: number | null } | undefined;
const revisionNumber = (lastRev?.maxRev ?? 0) + 1;
this.db.prepare(
"INSERT INTO agent_config_revisions (id, company_id, agent_id, revision_number, changed_keys, config_before, config_after, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
).run(
id, input.companyId, input.agentId, revisionNumber,
JSON.stringify(input.changedKeys),
JSON.stringify(input.configBefore),
JSON.stringify(input.configAfter),
input.source ?? "patch",
input.rolledBackFromRevisionId ?? null,
input.createdByAgentId ?? null,
input.createdByUserId ?? null,
ts
);
return id;
}
listConfigRevisions(agentId: string, companyId: string): Array<{
id: string; agentId: string; revisionNumber: number; changedKeys: string[];
source: string; rolledBackFromRevisionId: string | null;
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
}> {
return this.readAll<any>(
"SELECT id, agent_id, revision_number, changed_keys, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at FROM agent_config_revisions WHERE agent_id = ? AND company_id = ? ORDER BY revision_number DESC",
agentId, companyId
).map(row => ({
id: row.id,
agentId: row.agent_id,
revisionNumber: row.revision_number,
changedKeys: parseJson<string[]>(row.changed_keys, []),
source: row.source,
rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null,
createdByAgentId: row.created_by_agent_id ?? null,
createdByUserId: row.created_by_user_id ?? null,
createdAt: row.created_at,
}));
}
getConfigRevision(revisionId: string, companyId: string): {
id: string; agentId: string; revisionNumber: number; changedKeys: string[];
configBefore: Record<string, unknown>; configAfter: Record<string, unknown>;
source: string; rolledBackFromRevisionId: string | null;
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
} {
const row = this.db.prepare(
"SELECT * FROM agent_config_revisions WHERE id = ? AND company_id = ?"
).get(revisionId, companyId) as any;
if (!row) throw new Error(`Config revision ${revisionId} not found.`);
return {
id: row.id,
agentId: row.agent_id,
revisionNumber: row.revision_number,
changedKeys: parseJson<string[]>(row.changed_keys, []),
configBefore: parseJson<Record<string, unknown>>(row.config_before, {}),
configAfter: parseJson<Record<string, unknown>>(row.config_after, {}),
source: row.source,
rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null,
createdByAgentId: row.created_by_agent_id ?? null,
createdByUserId: row.created_by_user_id ?? null,
createdAt: row.created_at,
};
}
rollbackConfigRevision(agentId: string, revisionId: string, companyId: string): string {
return this.transaction(() => {
const revision = this.getConfigRevision(revisionId, companyId);
if (revision.agentId !== agentId) throw new Error("Revision does not belong to this agent.");
const before = revision.configBefore as Record<string, any>;
const ts = nowIso();
this.db.prepare(
"UPDATE agents SET name = ?, role = ?, title = ?, department = ?, capabilities = ?, connector_id = ?, model = ?, budget_monthly_usd = ?, metadata_json = ?, updated_at = ? WHERE id = ? AND company_id = ?"
).run(
before.name ?? "", before.role ?? "", before.title ?? "", before.department ?? null,
before.capabilities ?? "", before.connectorId ?? before.connector_id ?? "",
before.model ?? null, before.budgetMonthlyUsd ?? before.budget_monthly_usd ?? 0,
before.metadataJson ?? before.metadata_json ?? "{}", ts, agentId, companyId
);
const agent = this.getAgent(agentId);
const currentConfig = { name: agent.name, role: agent.role, title: agent.title, department: (agent as any).department, capabilities: agent.capabilities, connectorId: agent.connectorId, model: agent.model, budgetMonthlyUsd: agent.budgetMonthlyUsd, metadataJson: agent.metadataJson };
return this.recordConfigRevision({
companyId, agentId,
changedKeys: revision.changedKeys,
configBefore: currentConfig,
configAfter: before,
source: "rollback",
rolledBackFromRevisionId: revisionId,
});
});
}
appendRunEvent(input: {
companyId: string; runId: string; agentId: string;
eventType: string; stream?: string | null; level?: string | null;
message?: string | null; payload?: Record<string, unknown> | null;
}): void {
const ts = nowIso();
const lastSeq = this.db.prepare(
"SELECT MAX(seq) as maxSeq FROM run_events WHERE run_id = ?"
).get(input.runId) as { maxSeq: number | null } | undefined;
const seq = (lastSeq?.maxSeq ?? 0) + 1;
this.db.prepare(
"INSERT INTO run_events (company_id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
).run(
input.companyId, input.runId, input.agentId, seq,
input.eventType, input.stream ?? null, input.level ?? null,
input.message ?? null, input.payload ? JSON.stringify(input.payload) : null, ts
);
}
listRunEvents(runId: string, companyId: string, opts?: { offset?: number; limit?: number }): Array<{
id: number; runId: string; agentId: string; seq: number; eventType: string;
stream: string | null; level: string | null; message: string | null;
payload: Record<string, unknown> | null; createdAt: string;
}> {
const offset = opts?.offset ?? 0;
const limit = opts?.limit ?? 500;
return this.readAll<any>(
"SELECT id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at FROM run_events WHERE run_id = ? AND company_id = ? ORDER BY seq ASC LIMIT ? OFFSET ?",
runId, companyId, limit, offset
).map(row => ({
id: row.id,
runId: row.run_id,
agentId: row.agent_id,
seq: row.seq,
eventType: row.event_type,
stream: row.stream,
level: row.level,
message: row.message,
payload: parseJson<Record<string, unknown> | null>(row.payload, null),
createdAt: row.created_at,
}));
}
getAgentPermissions(agentId: string): Record<string, boolean> {
const row = this.db.prepare(
"SELECT permissions_json FROM agents WHERE id = ?"
).get(agentId) as { permissions_json: string } | undefined;
return parseJson<Record<string, boolean>>(row?.permissions_json, {});
}
setAgentPermission(agentId: string, companyId: string, permissionKey: string, enabled: boolean): void {
const current = this.getAgentPermissions(agentId);
if (enabled) {
current[permissionKey] = true;
} else {
delete current[permissionKey];
}
const ts = nowIso();
this.db.prepare(
"UPDATE agents SET permissions_json = ?, updated_at = ? WHERE id = ? AND company_id = ?"
).run(JSON.stringify(current), ts, agentId, companyId);
}
hasAgentPermission(agentId: string, permissionKey: string): boolean {
const perms = this.getAgentPermissions(agentId);
return perms[permissionKey] === true;
}
getRuntimeState(agentId: string): Record<string, unknown> {
const row = this.db.prepare(
"SELECT runtime_state_json FROM agents WHERE id = ?"
).get(agentId) as { runtime_state_json: string | null } | undefined;
return parseJson<Record<string, unknown>>(row?.runtime_state_json, {});
}
setRuntimeState(agentId: string, companyId: string, state: Record<string, unknown>): void {
const ts = nowIso();
this.db.prepare(
"UPDATE agents SET runtime_state_json = ?, updated_at = ? WHERE id = ? AND company_id = ?"
).run(JSON.stringify(state), ts, agentId, companyId);
}
getHeartbeatPolicy(agentId: string): {
enabled: boolean; intervalSec: number; cooldownSec: number;
maxConcurrentRuns: number; wakeOnDemand: boolean;
} {
const row = this.db.prepare(
"SELECT heartbeat_enabled, heartbeat_interval_sec, heartbeat_policy_json FROM agents WHERE id = ?"
).get(agentId) as { heartbeat_enabled: number; heartbeat_interval_sec: number; heartbeat_policy_json: string | null } | undefined;
if (!row) throw new Error(`Agent ${agentId} not found.`);
const policy = parseJson<Record<string, unknown>>(row.heartbeat_policy_json, {});
return {
enabled: toBoolean(row.heartbeat_enabled),
intervalSec: row.heartbeat_interval_sec ?? 120,
cooldownSec: typeof policy.cooldownSec === "number" ? policy.cooldownSec : 10,
maxConcurrentRuns: typeof policy.maxConcurrentRuns === "number" ? policy.maxConcurrentRuns : 1,
wakeOnDemand: policy.wakeOnDemand !== false,
};
}
setHeartbeatPolicy(agentId: string, companyId: string, policy: {
enabled?: boolean; intervalSec?: number; cooldownSec?: number;
maxConcurrentRuns?: number; wakeOnDemand?: boolean;
}): void {
const ts = nowIso();
const current = this.getHeartbeatPolicy(agentId);
const merged = { ...current, ...policy };
const policyJson = JSON.stringify({
cooldownSec: merged.cooldownSec,
maxConcurrentRuns: merged.maxConcurrentRuns,
wakeOnDemand: merged.wakeOnDemand,
});
this.db.prepare(
"UPDATE agents SET heartbeat_enabled = ?, heartbeat_interval_sec = ?, heartbeat_policy_json = ?, updated_at = ? WHERE id = ? AND company_id = ?"
).run(merged.enabled ? 1 : 0, merged.intervalSec, policyJson, ts, agentId, companyId);
}
countActiveRunsForAgent(agentId: string, companyId: string): number {
const row = this.db.prepare(
"SELECT COUNT(*) as cnt FROM runs WHERE agent_id = ? AND company_id = ? AND status IN ('queued', 'running')"
).get(agentId, companyId) as { cnt: number } | undefined;
return row?.cnt ?? 0;
}
updateRunActor(runId: string, actorType: string, actorId: string | null): void {
this.db.prepare(
"UPDATE runs SET actor_type = ?, actor_id = ?, updated_at = ? WHERE id = ?"
).run(actorType, actorId, nowIso(), runId);
}
updateCompanySkillMetadata(companyId: string, skillId: string, metadataJson: string): void {
const ts = nowIso();
this.db.prepare(
"UPDATE company_skills SET metadata = ?, updated_at = ? WHERE id = ? AND company_id = ?"
).run(metadataJson, ts, skillId, companyId);
}
private openConnection(): SqliteDatabase {
return new DatabaseSync(this.dbPath, { timeout: 5000 });
}
+96
View File
@@ -0,0 +1,96 @@
import log from "electron-log/main.js";
import type { HireApprovedPayload, HireApprovedHookResult } from "@agentcompany/adapter-utils";
import { getConnectorDefinition } from "./connectors";
import type { AppDatabase } from "./database";
export type { HireApprovedPayload, HireApprovedHookResult };
/**
* Notify the adapter that an agent hire was approved.
* Non-fatal: failures are logged but never thrown.
* Follows the Paperclip hire-hook.ts pattern.
*/
export async function notifyHireApproved(
db: AppDatabase,
input: {
companyId: string;
agentId: string;
source: "join_request" | "approval";
sourceId: string;
approvedAt?: string;
},
): Promise<void> {
try {
let agent;
try {
agent = db.getAgent(input.agentId);
} catch {
log.warn(`[hire-hook] Agent ${input.agentId} not found, skipping hire notification.`);
return;
}
let connectorDef;
try {
connectorDef = getConnectorDefinition(agent.connectorId);
} catch {
log.warn(`[hire-hook] No connector definition for ${agent.connectorId}, skipping hire notification.`);
return;
}
const adapter = connectorDef.module;
if (!adapter.onHireApproved) {
// Adapter doesn't implement the hook — this is normal, not an error
return;
}
const payload: HireApprovedPayload = {
companyId: input.companyId,
agentId: input.agentId,
agentName: agent.name,
adapterType: connectorDef.id,
source: input.source,
sourceId: input.sourceId,
approvedAt: input.approvedAt ?? new Date().toISOString(),
message: db.listSnapshot().locale === "zh"
? `你已被批准为"${agent.title}"。你现在已激活,可以接收任务。`
: `Your hire as "${agent.title}" in the company has been approved. You are now active and ready to receive tasks.`,
};
const result = await adapter.onHireApproved(payload, {});
if (result.ok) {
log.info(`[hire-hook] Successfully notified adapter for agent ${agent.name} (${input.agentId})`);
db.addActivity({
companyId: input.companyId,
actor: "system",
action: "hire_hook.success",
entityType: "agent",
entityId: input.agentId,
detail: `Hire notification sent to ${connectorDef.id} adapter`,
});
} else {
log.warn(`[hire-hook] Adapter returned error for agent ${agent.name}: ${result.error}`);
db.addActivity({
companyId: input.companyId,
actor: "system",
action: "hire_hook.failed",
entityType: "agent",
entityId: input.agentId,
detail: `Adapter error: ${result.error ?? "unknown"}`,
});
}
} catch (error) {
// Non-fatal: log and continue
const message = error instanceof Error ? error.message : String(error);
log.error(`[hire-hook] Exception notifying hire for agent ${input.agentId}: ${message}`);
try {
db.addActivity({
companyId: input.companyId,
actor: "system",
action: "hire_hook.exception",
entityType: "agent",
entityId: input.agentId,
detail: message,
});
} catch { /* activity logging itself failed — nothing more we can do */ }
}
}
+68
View File
@@ -4,10 +4,20 @@ import {
approvalInputSchema,
cancelRunSchema,
commentInputSchema,
getAgentPermissionsSchema,
getConfigRevisionSchema,
heartbeatPolicySchema,
heartbeatSettingsSchema,
listCommentsSchema,
listConfigRevisionsSchema,
listRunEventsSchema,
rollbackConfigRevisionSchema,
runtimeStateSchema,
runLogChunkSchema,
runLogSchema,
setAgentPermissionSchema,
setHeartbeatPolicySchema,
setRuntimeStateSchema,
startRunSchema,
triggerHeartbeatSchema,
} from "@shared/contracts";
@@ -240,4 +250,62 @@ export function registerCoreOperationHandlers({
}
return ok(runId);
});
registerHandle(IPC_CHANNELS.listConfigRevisions, async (_event, payload) => {
const parsed = listConfigRevisionsSchema.parse(payload);
return ok(db.listConfigRevisions(parsed.agentId, parsed.companyId));
});
registerHandle(IPC_CHANNELS.getConfigRevision, async (_event, payload) => {
const parsed = getConfigRevisionSchema.parse(payload);
return ok(db.getConfigRevision(parsed.revisionId, parsed.companyId));
});
registerHandle(IPC_CHANNELS.rollbackConfigRevision, async (_event, payload) => {
const parsed = rollbackConfigRevisionSchema.parse(payload);
const newRevisionId = db.rollbackConfigRevision(parsed.agentId, parsed.revisionId, parsed.companyId);
publishDomainChanged();
return ok({ revisionId: newRevisionId });
});
registerHandle(IPC_CHANNELS.listRunEvents, async (_event, payload) => {
const parsed = listRunEventsSchema.parse(payload);
return ok(db.listRunEvents(parsed.runId, parsed.companyId, { offset: parsed.offset, limit: parsed.limit }));
});
registerHandle(IPC_CHANNELS.getAgentPermissions, async (_event, payload) => {
const parsed = getAgentPermissionsSchema.parse(payload);
return ok(db.getAgentPermissions(parsed.agentId));
});
registerHandle(IPC_CHANNELS.setAgentPermission, async (_event, payload) => {
const parsed = setAgentPermissionSchema.parse(payload);
db.setAgentPermission(parsed.agentId, parsed.companyId, parsed.permissionKey, parsed.enabled);
publishDomainChanged();
return ok(null);
});
registerHandle(IPC_CHANNELS.getHeartbeatPolicy, async (_event, payload) => {
const parsed = heartbeatPolicySchema.parse(payload);
return ok(db.getHeartbeatPolicy(parsed.agentId));
});
registerHandle(IPC_CHANNELS.setHeartbeatPolicy, async (_event, payload) => {
const parsed = setHeartbeatPolicySchema.parse(payload);
const { companyId, agentId, ...policy } = parsed;
db.setHeartbeatPolicy(agentId, companyId, policy);
publishDomainChanged();
return ok(null);
});
registerHandle(IPC_CHANNELS.getRuntimeState, async (_event, payload) => {
const parsed = runtimeStateSchema.parse(payload);
return ok(db.getRuntimeState(parsed.agentId));
});
registerHandle(IPC_CHANNELS.setRuntimeState, async (_event, payload) => {
const parsed = setRuntimeStateSchema.parse(payload);
db.setRuntimeState(parsed.agentId, parsed.companyId, parsed.state);
return ok(null);
});
}
+11 -5
View File
@@ -99,9 +99,10 @@ export function createOnboardingService({
function createDefaultWorkspace(companyId: string, workspacePath: string) {
const trimmedPath = workspacePath.trim();
if (!trimmedPath) return null;
const wsLocale = db.listSnapshot().locale;
return db.saveWorkspace({
companyId,
name: "Default Workspace",
name: wsLocale === "zh" ? "默认工作区" : "Default Workspace",
localPath: trimmedPath,
repoUrl: "",
repoRef: "",
@@ -143,7 +144,10 @@ export function createOnboardingService({
status: "in_progress",
});
const kickoffTaskTitle = activeGoal ? `Operationalize goal: ${activeGoal.title}` : "Establish the first operating cadence";
const kickoffLocale = db.listSnapshot().locale;
const kickoffTaskTitle = activeGoal
? (kickoffLocale === "zh" ? `落地目标:${activeGoal.title}` : `Operationalize goal: ${activeGoal.title}`)
: (kickoffLocale === "zh" ? "建立首个运营节奏" : "Establish the first operating cadence");
const kickoffTaskDescription = activeGoal
? [
`Read the founding goal "${activeGoal.title}" and convert it into a real operating plan.`,
@@ -213,17 +217,19 @@ export function createOnboardingService({
});
}
const ceoLocale = db.listSnapshot().locale;
const ceoId = db.saveAgent({
companyId,
name: "CEO",
role: "CEO",
title: "Chief Executive Officer",
title: ceoLocale === "zh" ? "首席执行官" : "Chief Executive Officer",
reportsTo: null,
connectorId: selectedConnectorId,
workspaceId,
model: input.connectorModel?.trim() || null,
capabilities:
"Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
capabilities: ceoLocale === "zh"
? "高管领导力、战略规划、团队建设、目标分解、授权管理和组织运营。负责招募领导团队、制定战略,并协调公司所有运营活动。"
: "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
budgetMonthlyUsd: 250,
status: "idle",
});
+42 -7
View File
@@ -55,6 +55,7 @@ interface EnqueueRunPayload {
interface OrchestrationDependencies {
db: AppDatabase;
logger: {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
@@ -362,7 +363,7 @@ export function createOrchestrationService({
if (!hasWorkerReady()) return null;
const companyStatus = db.getCompanyStatus(companyId);
if (companyStatus !== "active") {
logger.info(`[heartbeat] Skipping ${agentId} because company ${companyId} is not active.`);
logger.debug(`[heartbeat] Skipping ${agentId} because company ${companyId} is not active.`);
return null;
}
const agent = db.getAgent(agentId);
@@ -374,26 +375,52 @@ export function createOrchestrationService({
if (getRunEligibilityFailure(agent, connector, null)) {
return null;
}
if (hasActiveRun({ agentId: agent.id, companyId })) {
logger.info(`[heartbeat] Skipping ${agent.name} because it already has an active run.`);
// Extended heartbeat policy checks (Paperclip parity)
let heartbeatPolicy = { cooldownSec: 10, maxConcurrentRuns: 1, wakeOnDemand: true };
try {
heartbeatPolicy = db.getHeartbeatPolicy(agent.id);
} catch {
// Non-fatal: use defaults
}
// maxConcurrentRuns check: count active runs for this agent
const activeRunCount = db.countActiveRunsForAgent(agent.id, companyId);
if (activeRunCount >= heartbeatPolicy.maxConcurrentRuns) {
logger.debug(`[heartbeat] Skipping ${agent.name} — active runs (${activeRunCount}) >= maxConcurrentRuns (${heartbeatPolicy.maxConcurrentRuns}).`);
return null;
}
// Cooldown check: ensure enough time has passed since last heartbeat
if (agent.lastHeartbeatAt) {
const elapsed = Date.now() - new Date(agent.lastHeartbeatAt).getTime();
const cooldownMs = heartbeatPolicy.cooldownSec * 1000;
if (elapsed < cooldownMs) {
logger.debug(`[heartbeat] Skipping ${agent.name} — cooldown (${heartbeatPolicy.cooldownSec}s) not elapsed.`);
return null;
}
}
// wakeOnDemand gate: if disabled, only timer/bootstrap triggers allowed
if (!heartbeatPolicy.wakeOnDemand && trigger !== "timer" && trigger !== "bootstrap") {
logger.debug(`[heartbeat] Skipping ${agent.name} — wakeOnDemand is disabled and trigger is "${trigger}".`);
return null;
}
if (db.isAgentPendingApproval(agentId)) {
logger.info(`[heartbeat] Skipping ${agent.name} because agent is pending hire approval.`);
logger.debug(`[heartbeat] Skipping ${agent.name} because agent is pending hire approval.`);
return null;
}
// Block heartbeat when agent has tasks awaiting user deliverable review
// Check both: pending approval (gate created) and requires_user_review flag (gap before approval)
if (db.hasAgentPendingDeliverableReview(agentId, companyId) || db.hasAgentTaskAwaitingReview(agentId, companyId)) {
logger.info(`[heartbeat] Skipping ${agent.name} — awaiting user deliverable review.`);
logger.debug(`[heartbeat] Skipping ${agent.name} — awaiting user deliverable review.`);
return null;
}
// Block CEO heartbeat when company has any pending deliverable reviews
// CEO should not create new work while the user hasn't reviewed existing deliverables
if (!agent.reportsTo && db.hasPendingDeliverableReviews(companyId)) {
logger.info(`[heartbeat] Skipping CEO ${agent.name} — company has pending deliverable reviews.`);
logger.debug(`[heartbeat] Skipping CEO ${agent.name} — company has pending deliverable reviews.`);
return null;
}
@@ -418,7 +445,7 @@ export function createOrchestrationService({
const companyNeedsBootstrap = goalCount > 0 && taskCount < goalCount;
if (isTimer && !isCeo && !isManager && !companyNeedsBootstrap) {
logger.info(`[heartbeat] Skipping ${agent.name} — no actionable tasks and trigger is timer.`);
logger.debug(`[heartbeat] Skipping ${agent.name} — no actionable tasks and trigger is timer.`);
return null;
}
@@ -507,6 +534,14 @@ export function createOrchestrationService({
});
db.markHeartbeatAt(agentId);
// Actor audit: record what triggered this run
try {
const actorType = trigger === "timer" || trigger === "bootstrap" ? "system" : "agent";
const actorId = trigger === "timer" || trigger === "bootstrap" ? null : trigger;
db.updateRunActor(run.id, actorType, actorId);
} catch { /* Non-fatal */ }
db.addActivity({
companyId,
actor: "system",
+5
View File
@@ -1573,6 +1573,11 @@ Always verify API responses before reporting — only report successful actions.
- CRITICAL: Actually call the API to take actions. Do NOT just describe what you would do — DO it. Every wake cycle should result in at least one API call that moves work forward.
`);
// Reinforce locale at the end of prompt (recency bias helps LLMs follow this)
if (localeInstruction) {
sections.push(`# 语言要求提醒\n${localeInstruction}\n请记住:你的所有输出必须使用中文。\n`);
}
return sections.join("\n");
}
+2
View File
@@ -278,6 +278,8 @@ export function createServiceGraph({
publishDomainChanged,
scanAndAssignSkills: async (companyId: string, agentId: string) => {
const svc = getOrCreateSkillService();
await svc.seedBuiltinSkills(companyId);
svc.assignRoleSkillsToAgent(companyId, agentId, "CEO");
await svc.scanWorkspaceSkills(companyId);
autoAssignSkillsToAgent(db, logger, companyId, agentId);
},
+171 -24
View File
@@ -677,53 +677,197 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
): Promise<SkillImportResult[]> {
const allResults: SkillImportResult[] = [];
// 1. Scan user home directories: ~/.agents/skills/ and ~/.claude/skills/
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
if (homeDir) {
const homeScanDirs = [
join(homeDir, ".agents", "skills"),
join(homeDir, ".claude", "skills"),
];
for (const scanDir of homeScanDirs) {
if (!existsSync(scanDir)) continue;
try {
const stat = statSync(scanDir);
if (!stat.isDirectory()) continue;
const results = await importFromLocal(companyId, scanDir);
// 1. Scan profile skills directory: {profileDir}/skills/{companyId}/
// This is where builtin and user-installed skills live
const profileSkillsDir = skillsRoot(companyId);
if (existsSync(profileSkillsDir)) {
try {
const stat = statSync(profileSkillsDir);
if (stat.isDirectory()) {
const results = await importFromLocal(companyId, profileSkillsDir);
allResults.push(...results);
logger.info(`Scanned home skills: ${scanDir} — imported ${results.length} skill(s)`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn(`Failed to scan home skills at ${scanDir}: ${message}`);
logger.info(`Scanned profile skills: ${profileSkillsDir} — imported ${results.length} skill(s)`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn(`Failed to scan profile skills at ${profileSkillsDir}: ${message}`);
}
}
// 2. Scan workspace .agents/skills/ directories
// 2. Scan workspace .agentcompany/skills/ directories
const workspacePaths = db.listWorkspaceLocalPaths(companyId);
for (const wsPath of workspacePaths) {
const agentsSkillsDir = join(wsPath, ".agents", "skills");
if (!existsSync(agentsSkillsDir)) continue;
const agentcompanySkillsDir = join(wsPath, ".agentcompany", "skills");
if (!existsSync(agentcompanySkillsDir)) continue;
try {
const stat = statSync(agentsSkillsDir);
const stat = statSync(agentcompanySkillsDir);
if (!stat.isDirectory()) continue;
const results = await importFromLocal(companyId, agentsSkillsDir);
const results = await importFromLocal(companyId, agentcompanySkillsDir);
allResults.push(...results);
logger.info(
`Scanned workspace skills: ${agentsSkillsDir} — imported ${results.length} skill(s)`,
`Scanned workspace skills: ${agentcompanySkillsDir} — imported ${results.length} skill(s)`,
);
} catch (err) {
// Log but don't fail the entire scan if one workspace has issues
const message = err instanceof Error ? err.message : String(err);
logger.warn(`Failed to scan workspace skills at ${agentsSkillsDir}: ${message}`);
logger.warn(`Failed to scan workspace skills at ${agentcompanySkillsDir}: ${message}`);
}
}
return allResults;
}
/**
* Resolve the path to bundled builtin skills.
* Cross-platform: works on macOS, Windows, and Linux.
*
* Production (packaged app):
* macOS: AppName.app/Contents/Resources/builtin-skills/
* Windows: {install dir}/resources/builtin-skills/
* Linux: {install dir}/resources/builtin-skills/
* Resolved via `process.resourcesPath` (set by Electron on all platforms).
*
* Development (electron-vite dev):
* CWD is the project root → {project}/builtin-skills/
* No __dirname dependency (ESM polyfill varies across bundlers).
*/
function getBuiltinSkillsDir(): string | null {
// 1. Production: Electron packages extraResources into process.resourcesPath
// process.resourcesPath is always defined in Electron (all platforms)
if (process.resourcesPath) {
const resourcesPath = join(process.resourcesPath, "builtin-skills");
if (existsSync(resourcesPath)) return resourcesPath;
}
// 2. Development: electron-vite dev sets CWD to project root
const cwdPath = join(process.cwd(), "builtin-skills");
if (existsSync(cwdPath)) return cwdPath;
return null;
}
/**
* Seed all builtin skills for a company from the bundled builtin-skills directory.
* Idempotent: skips skills that already exist (by key).
* Called once during company bootstrap.
*/
async function seedBuiltinSkills(companyId: string): Promise<SkillImportResult[]> {
const builtinDir = getBuiltinSkillsDir();
if (!builtinDir) {
logger.warn("[skills] Builtin skills directory not found — skipping seed.");
return [];
}
const allResults: SkillImportResult[] = [];
// Walk each category directory (basic-for-all, software-engineer, etc.)
let categories: string[];
try {
categories = readdirSync(builtinDir).filter(name => {
if (name.startsWith(".")) return false;
try { return statSync(join(builtinDir, name)).isDirectory(); } catch { return false; }
});
} catch {
logger.warn(`[skills] Failed to read builtin skills directory: ${builtinDir}`);
return [];
}
for (const category of categories) {
const categoryDir = join(builtinDir, category);
try {
const results = await importFromLocal(companyId, categoryDir);
// Tag each imported skill with builtin metadata so we know its category
for (const result of results) {
try {
const skill = db.getCompanySkill(companyId, result.skillId);
if (skill) {
const existingMeta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {};
existingMeta.builtinCategory = category;
existingMeta.sourceKind = "builtin";
db.updateCompanySkillMetadata(companyId, result.skillId, JSON.stringify(existingMeta));
}
} catch { /* non-fatal metadata update */ }
}
allResults.push(...results);
logger.info(`[skills] Seeded ${results.length} builtin skill(s) from category: ${category}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn(`[skills] Failed to seed builtin category ${category}: ${message}`);
}
}
return allResults;
}
/**
* Auto-assign all skills from the "basic-for-all" builtin category to an agent.
*/
function assignBasicSkillsToAgent(companyId: string, agentId: string): number {
const allSkills = db.listCompanySkills(companyId);
let assigned = 0;
for (const skill of allSkills) {
try {
const meta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {};
if (meta.builtinCategory === "basic-for-all") {
db.assignSkillToAgent(agentId, skill.id);
assigned++;
}
} catch { /* skip malformed metadata */ }
}
return assigned;
}
/**
* Auto-assign role-appropriate builtin skills to an agent based on its role/department.
* CEO gets all categories. Other agents get basic-for-all + role-matched categories.
*/
function assignRoleSkillsToAgent(companyId: string, agentId: string, role: string, department?: string | null): number {
const allSkills = db.listCompanySkills(companyId);
let assigned = 0;
// Map roles/departments to skill categories
const roleCategories: Record<string, string[]> = {
ceo: ["basic-for-all", "software-engineer", "document-process", "frontend-design", "marketing", "iOS-Dev", "my-writing-style", "Video-maker"],
engineer: ["basic-for-all", "software-engineer"],
designer: ["basic-for-all", "frontend-design"],
marketing: ["basic-for-all", "marketing", "my-writing-style"],
writer: ["basic-for-all", "document-process", "my-writing-style"],
ios: ["basic-for-all", "software-engineer", "iOS-Dev"],
mobile: ["basic-for-all", "software-engineer", "iOS-Dev"],
video: ["basic-for-all", "Video-maker"],
frontend: ["basic-for-all", "software-engineer", "frontend-design"],
};
const normalizedRole = role.toLowerCase();
const normalizedDept = department?.toLowerCase() ?? "";
// Find matching categories
let categories = roleCategories[normalizedRole] ?? ["basic-for-all"];
// Also check department
for (const [key, cats] of Object.entries(roleCategories)) {
if (normalizedDept.includes(key) || normalizedRole.includes(key)) {
categories = [...new Set([...categories, ...cats])];
}
}
// If CEO role, assign all
if (normalizedRole === "ceo" || normalizedRole.includes("chief")) {
categories = roleCategories.ceo!;
}
for (const skill of allSkills) {
try {
const meta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {};
if (meta.builtinCategory && categories.includes(meta.builtinCategory)) {
db.assignSkillToAgent(agentId, skill.id);
assigned++;
}
} catch { /* skip */ }
}
return assigned;
}
return {
importFromSource,
importFromGitHub,
@@ -737,5 +881,8 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
checkUpdateStatus,
listRuntimeSkillEntries,
scanWorkspaceSkills,
seedBuiltinSkills,
assignBasicSkillsToAgent,
assignRoleSkillsToAgent,
};
}
+9 -2
View File
@@ -43,6 +43,7 @@ type WorkerUiEvent = Extract<z.infer<typeof workerMessageSchema>, { type: "run-l
interface WorkerServiceDependencies {
db: AppDatabase;
logger: {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
@@ -105,8 +106,14 @@ export function createWorkerService({
serviceName: "AgentCompany Orchestrator",
});
worker.stdout?.on("data", (chunk) => logger.info(`[worker] ${String(chunk).trim()}`));
worker.stderr?.on("data", (chunk) => logger.error(`[worker] ${String(chunk).trim()}`));
worker.stdout?.on("data", (chunk) => {
const text = String(chunk).trim();
if (text) logger.debug(`[worker:stdout] ${text}`);
});
worker.stderr?.on("data", (chunk) => {
const text = String(chunk).trim();
if (text) logger.warn(`[worker:stderr] ${text}`);
});
worker.on("exit", (code) => {
if (isQuitting() || destroyed) return;