This commit is contained in:
ZacharyZhang-NY committed 2026-04-17 14:03:56 -04:00
1 parent 806200b3d1
commit 5547c29491
216 files changed
+5043 -3352

No files matched your search

-1
View File
@@ -1,6 +1,5 @@
AGENTCOMPANY_UPDATE_URL=
AGENTCOMPANY_DISABLE_UPDATES=true
AGENTCOMPANY_ALLOW_UNSAFE_CONNECTOR_BYPASS=false
CSC_LINK=
CSC_KEY_PASSWORD=
WIN_CSC_LINK=
-1
View File
@@ -42,7 +42,6 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm typecheck:all
# ─────────────────────────────────────────────
# Stage 2: Unit Tests
+2
View File
@@ -28,6 +28,8 @@ playwright-report/
*.pem
*.key
*.p8
*.csr
*.certSigningRequest
*.provisionprofile
*.mobileprovision
+1 -2
View File
@@ -24,12 +24,11 @@
"lint:fix": "eslint . --fix",
"test": "node --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs run",
"typecheck": "tsc --noEmit",
"typecheck:all": "pnpm -r typecheck",
"test:unit": "node --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs run --config vitest.config.ts tests/unit",
"test:integration": "node --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs run --config vitest.config.ts tests/integration",
"test:e2e": "node --no-warnings ./node_modules/@playwright/test/cli.js test -c tests/e2e/playwright.config.ts",
"test:run": "pnpm test:unit && pnpm test:integration && pnpm test:e2e",
"verify": "pnpm typecheck && pnpm lint && pnpm typecheck:all && pnpm test:unit && pnpm test:integration && pnpm test:e2e",
"verify": "pnpm typecheck && pnpm lint && pnpm test:unit && pnpm test:integration && pnpm test:e2e",
"verify:release": "pnpm verify && pnpm build:dir",
"postinstall": "node scripts/postinstall.mjs"
},
-1
View File
@@ -1 +0,0 @@
packages: []
+38 -2
View File
@@ -160,6 +160,30 @@ function getDockerBuildCommands(platform) {
];
}
// Paths under rootDir that the container needs. Credentials, env files, docs,
// screenshots, pi-mono-reference, and other host-only state are deliberately
// excluded to keep secrets out of the Wine/Linux builder image.
const containerSourceMounts = [
{ host: "src", mode: "ro" },
{ host: "scripts", mode: "ro" },
{ host: "assets", mode: "ro" },
{ host: "builtin-skills", mode: "ro" },
{ host: "electron-builder.yml", mode: "ro" },
{ host: "package.json", mode: "ro" },
{ host: "pnpm-lock.yaml", mode: "ro" },
{ host: "electron.vite.config.ts", mode: "ro" },
{ host: "tsconfig.json", mode: "ro" },
{ host: "tsconfig.base.json", mode: "ro" },
{ host: "tsconfig.check.json", mode: "ro" },
{ host: "eslint.config.mjs", mode: "ro" },
];
// Writable host paths — electron-vite writes to out/, electron-builder writes to build/.
const containerOutputMounts = [
{ host: "out" },
{ host: "build" },
];
async function runDockerBuild(platform) {
const isWindows = platform === "win";
const image = isWindows ? "electronuserland/builder:wine" : "electronuserland/builder";
@@ -172,16 +196,28 @@ async function runDockerBuild(platform) {
"pnpm exec electron-vite build",
...getDockerBuildCommands(platform),
].join(" && ");
// Ensure host-side output dirs exist before binding (docker would otherwise create
// them as root-owned on Linux hosts).
for (const mount of containerOutputMounts) {
await mkdir(join(rootDir, mount.host), { recursive: true });
}
const dockerArgs = [
"run",
"--rm",
"--platform", "linux/amd64",
"-v", `${rootDir}:/project`,
];
for (const mount of containerSourceMounts) {
dockerArgs.push("-v", `${join(rootDir, mount.host)}:/project/${mount.host}:${mount.mode}`);
}
for (const mount of containerOutputMounts) {
dockerArgs.push("-v", `${join(rootDir, mount.host)}:/project/${mount.host}`);
}
dockerArgs.push(
"-v", `${dockerNodeModulesVolume}:/project/node_modules`,
"-v", `${join(cacheDir, "electron")}:/root/.cache/electron`,
"-v", `${join(cacheDir, "electron-builder")}:/root/.cache/electron-builder`,
"-w", "/project",
];
);
for (const [key, value] of Object.entries(env)) {
dockerArgs.push("-e", `${key}=${value}`);
}
-65
View File
@@ -1,65 +0,0 @@
import type { AppDatabase } from "@main/database";
import type { DesktopEvent } from "@shared/types";
interface BeforeRequestPayload {
agentId: string;
companyId: string;
taskId: string | null;
toolCallId: string;
toolName: string;
args: unknown;
}
interface AfterRequestPayload {
toolCallId: string;
toolName: string;
isError: boolean;
}
export interface HumanApprovalHookDeps {
db: AppDatabase;
emitEvent: (event: DesktopEvent) => void;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
}
/**
* pi `beforeToolCall` / `afterToolCall` bridge.
*
* **Execution-mode contract.** Both AI CEO and AI Co-Founder modes run the
* pi Agent fully autonomously — the framework layer never gates a tool
* call with a synchronous human-approval promise. Under AI Co-Founder, the
* model itself is instructed (via the system prompt + a dedicated
* `request_approval` tool) to pause when it judges human review necessary.
* Blocking every `bash` / `edit` / `write` at the Electron layer would
* defeat the pi-mono runtime and starve the agent of progress.
*
* `before` therefore always approves. `after` is a no-op audit point; the
* agent-event stream persists the real tool record via SqliteSessionStore.
* `cancelAll` remains for shutdown symmetry but has no queued callers.
*/
export class HumanApprovalHook {
constructor(private readonly _deps: HumanApprovalHookDeps) {
void this._deps;
}
async before(_runId: string, _payload: BeforeRequestPayload): Promise<{ block?: boolean; reason?: string }> {
return {};
}
async after(_runId: string, _payload: AfterRequestPayload): Promise<void> {
/* no-op */
}
/**
* Retained so `RuntimeBridge.release()` can call it during app shutdown
* without knowing whether any approvals were actually queued. The
* autonomous hook never queues anything, so this is always a no-op.
*/
cancelAll(_reason: string): void {
/* no-op */
}
}
+19 -17
View File
@@ -5,7 +5,6 @@ import { resolveModel } from "@main/model-resolver";
export interface CostWriterDeps {
db: AppDatabase;
logger: { warn: (...args: unknown[]) => void };
}
interface ObserveInput {
@@ -26,21 +25,24 @@ export class CostWriter {
constructor(private readonly deps: CostWriterDeps) {}
observeAssistantMessage(input: ObserveInput): void {
let amountUsd: number | null = null;
try {
const definition = getConnectorDefinition(input.connectorId);
if (definition.provider) {
const model = resolveModel(definition.provider, input.modelId);
if (model) {
const cost = calculateCost(model, input.usage);
amountUsd = Number.isFinite(cost.total) ? cost.total : null;
}
}
} catch (error) {
this.deps.logger.warn(
`[cost-writer] cost calculation failed for ${input.connectorId}/${input.modelId}: ${error instanceof Error ? error.message : String(error)}`,
// Fail closed on cost calculation — budget policies read `cost_entries`
// to enforce spending gates; an "unattributed" row would silently let
// an agent run unbounded. If pricing cannot be resolved, refuse to
// persist the row and let the caller see the error. The failure is
// visible (throws) instead of invisible (null amount, unattributed=1).
const definition = getConnectorDefinition(input.connectorId);
if (!definition.provider) {
throw new Error(`[cost-writer] connector ${input.connectorId} has no provider mapping`);
}
const model = resolveModel(definition.provider, input.modelId);
if (!model) {
throw new Error(`[cost-writer] model ${input.modelId} not found for provider ${definition.provider}`);
}
const cost = calculateCost(model, input.usage);
if (!Number.isFinite(cost.total)) {
throw new Error(
`[cost-writer] cost calculation returned non-finite total for ${input.connectorId}/${input.modelId}`,
);
amountUsd = null;
}
this.deps.db.queryAll(
@@ -51,8 +53,8 @@ export class CostWriter {
input.taskId,
input.agentId,
input.connectorId,
amountUsd,
amountUsd === null ? 1 : 0,
cost.total,
0,
new Date().toISOString(),
);
}
-1
View File
@@ -1,4 +1,3 @@
export { SqliteSessionStore } from "./sqlite-session-store";
export { HumanApprovalHook } from "./approval-hook";
export { CostWriter } from "./cost-writer";
export { RuntimeBridge } from "./runtime-bridge";
+34 -45
View File
@@ -3,7 +3,6 @@ import type { AppDatabase } from "@main/database";
import type { DesktopEvent } from "@shared/types";
import { ensureTaskThread } from "@main/thread-integration/thread-helpers";
import { SqliteSessionStore } from "./sqlite-session-store";
import { HumanApprovalHook } from "./approval-hook";
import { CostWriter } from "./cost-writer";
import type { CredentialResolver } from "@main/credentials";
import { getConnectorDefinition } from "@main/connectors";
@@ -132,35 +131,33 @@ interface ActiveRunContext {
* - Forwards `agent-event` from the worker to the renderer (push channel).
* - Persists each `message_end` message via `SqliteSessionStore`.
* - Calls `CostWriter` for assistant messages with usage.
* - Translates `beforeToolCall` RPC into an approval row + waits.
* - Translates `afterToolCall` RPC into best-effort audit hooks.
*
* Tool-call gating runs fully in the model layer (AI CEO and AI Co-Founder
* both autonomous); no `beforeToolCall` / `afterToolCall` IPC hop exists.
*/
export class RuntimeBridge {
readonly approvalHook: HumanApprovalHook;
private readonly costWriter: CostWriter;
private readonly activeRuns = new Map<string, ActiveRunContext>();
private readonly sessionStores = new Map<string, SqliteSessionStore>();
constructor(private readonly deps: RuntimeBridgeDeps) {
this.approvalHook = new HumanApprovalHook({
db: deps.db,
emitEvent: deps.emitEvent,
logger: deps.logger,
});
this.costWriter = new CostWriter({ db: deps.db, logger: deps.logger });
this.costWriter = new CostWriter({ db: deps.db });
}
registerActiveRun(context: ActiveRunContext): void {
private registerActiveRun(context: ActiveRunContext): void {
this.activeRuns.set(context.runId, context);
this.getOrCreateStore(context.taskSessionId);
}
releaseActiveRun(runId: string): void {
/**
* Evicts the per-run `activeRuns` context and the per-session store cache.
* Callers must invoke after a `run-finished` worker message so the maps
* stay bounded by concurrent-run count rather than process lifetime.
*/
releaseRunResources(runId: string): void {
const ctx = this.activeRuns.get(runId);
this.activeRuns.delete(runId);
}
loadPriorMessages(taskSessionId: string): AgentMessage[] {
return this.getOrCreateStore(taskSessionId).loadMessages();
if (ctx) this.sessionStores.delete(ctx.taskSessionId);
}
private getOrCreateStore(taskSessionId: string): SqliteSessionStore {
@@ -206,15 +203,26 @@ export class RuntimeBridge {
| { role?: string; usage?: unknown; content?: unknown; toolCallId?: string }
| undefined;
if (ctx && messageRaw?.role === "assistant" && messageRaw.usage) {
this.costWriter.observeAssistantMessage({
runId: ctx.runId,
companyId: ctx.companyId,
agentId: ctx.agentId,
taskId: ctx.taskId,
connectorId: ctx.connectorId,
modelId: ctx.modelId,
usage: messageRaw.usage as never,
});
try {
this.costWriter.observeAssistantMessage({
runId: ctx.runId,
companyId: ctx.companyId,
agentId: ctx.agentId,
taskId: ctx.taskId,
connectorId: ctx.connectorId,
modelId: ctx.modelId,
usage: messageRaw.usage as never,
});
} catch (error) {
// Cost writer fails closed (RUNTIME-105) — surface the failure
// loudly so operators can see a pricing catalog break, but let
// the run continue so the agent's work is not lost. The missing
// row means budget enforcement under-counts this message; a
// repeated failure in the same run is still visible in logs.
this.deps.logger.error(
`[runtime-bridge] cost recording failed for run ${ctx.runId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
this.recordThreadEvent(envelope, messageRaw);
}
@@ -265,30 +273,11 @@ export class RuntimeBridge {
/** Handles RPC requests issued by the worker (`rpc-request` envelope). */
async handleRpc(request: {
rpcId: string;
method: "beforeToolCall" | "afterToolCall" | "persistMessage" | "prepareRun";
method: "persistMessage" | "prepareRun";
runId: string;
payload: unknown;
}): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> {
try {
if (request.method === "beforeToolCall") {
const ctx = this.activeRuns.get(request.runId);
if (!ctx) return { ok: true, result: {} };
const payload = request.payload as {
agentId: string;
companyId: string;
taskId: string | null;
toolCallId: string;
toolName: string;
args: unknown;
};
const verdict = await this.approvalHook.before(request.runId, payload);
return { ok: true, result: verdict };
}
if (request.method === "afterToolCall") {
const payload = request.payload as { toolCallId: string; toolName: string; isError: boolean };
await this.approvalHook.after(request.runId, payload);
return { ok: true, result: undefined };
}
if (request.method === "persistMessage") {
const payload = request.payload as { taskSessionId: string; message: AgentMessage };
this.getOrCreateStore(payload.taskSessionId).appendMessage(payload.message);
@@ -19,12 +19,7 @@ export class SqliteSessionStore {
const result: AgentMessage[] = [];
for (const row of rows) {
if (row.kind === "message") {
try {
result.push(JSON.parse(row.payloadJson) as AgentMessage);
} catch {
// Skip malformed entries (defensive) — preserves the rest of the
// transcript instead of failing the entire load.
}
result.push(JSON.parse(row.payloadJson) as AgentMessage);
}
}
return result;
+19 -3
View File
@@ -1,3 +1,4 @@
import log from "electron-log/main.js";
import { z } from "zod";
import { canControlAgent } from "../agent-permissions";
import { requireParam, type AgentApiServer } from "./server-core";
@@ -82,7 +83,12 @@ export function registerAgentControlRoutes(server: AgentApiServer) {
server.addRoute("GET", "/api/agents/:agentId/skills", (_req, res, params, token) => {
const agentId = requireParam(params, "agentId");
let agent;
try { agent = db.getAgent(agentId); } catch { agent = null; }
try {
agent = db.getAgent(agentId);
} catch (error) {
log.warn(`[api/agent-skills:GET] getAgent(${agentId}) failed: ${error instanceof Error ? error.message : String(error)}`);
agent = null;
}
if (!agent || agent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found in your company" });
return;
@@ -109,7 +115,12 @@ export function registerAgentControlRoutes(server: AgentApiServer) {
return;
}
let agent;
try { agent = db.getAgent(agentId); } catch { agent = null; }
try {
agent = db.getAgent(agentId);
} catch (error) {
log.warn(`[api/agent-skills:POST] getAgent(${agentId}) failed: ${error instanceof Error ? error.message : String(error)}`);
agent = null;
}
if (!agent || agent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found in your company" });
return;
@@ -128,7 +139,12 @@ export function registerAgentControlRoutes(server: AgentApiServer) {
const agentId = requireParam(params, "agentId");
const skillId = requireParam(params, "skillId");
let agent;
try { agent = db.getAgent(agentId); } catch { agent = null; }
try {
agent = db.getAgent(agentId);
} catch (error) {
log.warn(`[api/agent-skills:DELETE] getAgent(${agentId}) failed: ${error instanceof Error ? error.message : String(error)}`);
agent = null;
}
if (!agent || agent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found in your company" });
return;
+15 -2
View File
@@ -166,7 +166,16 @@ export function registerAgentRoutes(server: AgentApiServer) {
}
const directReports = db.getDirectReports(agent.id, token.companyId);
const reportsTo = agent.reportsTo;
const manager = reportsTo ? (() => { try { return db.getAgent(reportsTo); } catch { return null; } })() : null;
const manager = reportsTo
? (() => {
try {
return db.getAgent(reportsTo);
} catch (error) {
log.warn(`[api/agents] manager lookup failed for ${reportsTo}: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
})()
: null;
server.json(res, 200, {
...agent,
manager: manager ? { id: manager.id, name: manager.name, role: manager.role } : null,
@@ -285,7 +294,11 @@ export function registerAgentRoutes(server: AgentApiServer) {
server.json(res, 409, { error: `A hire request for "${input.name}" is already pending approval.` });
return;
}
} catch { /* invalid payloadJson, skip */ }
} catch (error) {
log.warn(
`[api/agents] invalid pending-hire payloadJson for company ${companyId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
const managerLabel = resolvedReportsTo
+36 -8
View File
@@ -1,3 +1,4 @@
import log from "electron-log/main.js";
import { z } from "zod";
import { IMPORTANT_DOC_TYPE_SET } from "@shared/document-categories";
import { requireParam, type AgentApiServer } from "./server-core";
@@ -109,7 +110,11 @@ export function registerFileRoutes(server: AgentApiServer) {
if (run.companyId === token.companyId) {
taskId = run.taskId;
}
} catch { /* run may have finished or been deleted */ }
} catch (error) {
log.warn(
`[api/documents] getRun(${token.runId}) failed while deriving taskId: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// Fire document creation automation rules
callbacks.onDocumentCreated(token.companyId, token.agentId, projectId, id, docTitle, docType, taskId);
@@ -143,11 +148,19 @@ export function registerFileRoutes(server: AgentApiServer) {
if (agent.heartbeatEnabled) {
db.setHeartbeat(token.agentId, false, agent.heartbeatIntervalSec || 120);
}
} catch { /* best effort */ }
} catch (error) {
log.warn(
`[api/documents] heartbeat pause for agent ${token.agentId} failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
onDomainChanged();
}
}
} catch { /* deliverable review gate is best-effort */ }
} catch (error) {
log.warn(
`[api/documents] deliverable review gate failed for company ${token.companyId}, document ${id}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
server.json(res, 201, { id });
@@ -258,7 +271,10 @@ export function registerFileRoutes(server: AgentApiServer) {
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const products = db.listWorkProductsForTask(task.id, task.companyId);
server.json(res, 200, products);
} catch { server.json(res, 404, { error: "Task not found" }); }
} catch (error) {
log.warn(`[api/work-products] list failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`);
server.json(res, 404, { error: "Task not found" });
}
});
server.addRoute("POST", "/api/issues/:issueId/work-products", (_req, res, params, token, body) => {
@@ -286,7 +302,10 @@ export function registerFileRoutes(server: AgentApiServer) {
});
onDomainChanged();
server.json(res, 201, { id });
} catch { server.json(res, 404, { error: "Task not found" }); }
} catch (error) {
log.warn(`[api/work-products] create failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`);
server.json(res, 404, { error: "Task not found" });
}
});
server.addRoute("PATCH", "/api/work-products/:workProductId", (_req, res, params, token, body) => {
@@ -317,7 +336,10 @@ export function registerFileRoutes(server: AgentApiServer) {
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const links = db.listDocumentsForTask(task.id, task.companyId);
server.json(res, 200, links);
} catch { server.json(res, 404, { error: "Task not found" }); }
} catch (error) {
log.warn(`[api/task-documents] list failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`);
server.json(res, 404, { error: "Task not found" });
}
});
server.addRoute("PUT", "/api/issues/:issueId/documents/:key", (_req, res, params, token, body) => {
@@ -337,7 +359,10 @@ export function registerFileRoutes(server: AgentApiServer) {
const linkId = db.linkDocumentToTask(task.id, documentId, task.companyId, key);
onDomainChanged();
server.json(res, 200, { id: linkId, key });
} catch { server.json(res, 404, { error: "Task not found" }); }
} catch (error) {
log.warn(`[api/task-documents] link failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`);
server.json(res, 404, { error: "Task not found" });
}
});
server.addRoute("DELETE", "/api/issues/:issueId/documents/:key", (_req, res, params, token) => {
@@ -348,6 +373,9 @@ export function registerFileRoutes(server: AgentApiServer) {
db.unlinkDocumentFromTask(task.id, key, task.companyId);
onDomainChanged();
server.json(res, 200, { unlinked: true });
} catch { server.json(res, 404, { error: "Task not found" }); }
} catch (error) {
log.warn(`[api/task-documents] unlink failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`);
server.json(res, 404, { error: "Task not found" });
}
});
}
+9 -4
View File
@@ -1,9 +1,9 @@
import log from "electron-log/main.js";
import { z } from "zod";
import { taskInputSchema } from "@shared/contracts";
import { IMPORTANT_DOC_TYPES } from "@shared/document-categories";
import { canAssignTask, canUpdateTask } from "../agent-permissions";
import { inferTaskType } from "../connector-matching";
import { isHeartbeatTaskTitle } from "../orchestration/heartbeat-task";
import { validateTaskCreation } from "../task-quality-gates";
import { requireParam, type AgentApiServer } from "./server-core";
@@ -221,7 +221,11 @@ export function registerTaskRoutes(server: AgentApiServer) {
if (parentTask.assigneeAgentId && parentTask.assigneeAgentId !== token.agentId) {
onWakeAgent(parentTask.assigneeAgentId, task.companyId, "subtask_completed");
}
} catch { /* parent task may have been deleted */ }
} catch (error) {
log.warn(
`[api/tasks] parent task lookup failed for ${task.parentId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
} else {
const chain = db.getChainOfCommand(token.agentId);
if (chain.length > 0) {
@@ -304,8 +308,9 @@ export function registerTaskRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" });
return;
}
const isHeartbeat = isHeartbeatTaskTitle(input.title);
const taskValidation = validateTaskCreation({ title: input.title, description: input.description ?? "" }, isHeartbeat);
// Agent-authored tasks are never heartbeat tasks; the is_heartbeat flag
// is set only by acquireHeartbeatTask in the orchestrator.
const taskValidation = validateTaskCreation({ title: input.title, description: input.description ?? "" }, false);
if (!taskValidation.valid) {
server.json(res, 422, { error: taskValidation.errors.join("; ") });
return;
+14 -1
View File
@@ -4,6 +4,13 @@ interface AppLifecycleDependencies {
getMainWindow: () => Electron.BrowserWindow | null;
ensureWindow: () => Promise<Electron.BrowserWindow | null>;
shouldQuit: () => boolean;
/**
* Invoked on the Electron `before-quit` event. The lifecycle service will
* call `event.preventDefault()` on the first invocation so the owner of
* this hook can perform an awaited drain and then invoke `app.exit()`
* explicitly when the drain resolves. Subsequent `before-quit` events are
* ignored because the drain is already in flight.
*/
onBeforeQuit: () => void;
}
@@ -52,7 +59,13 @@ export function createAppLifecycleService({
app.quit();
});
app.on("before-quit", () => {
let drainStarted = false;
app.on("before-quit", (event) => {
if (drainStarted) return;
drainStarted = true;
// Block Electron's default quit until the owner's drain resolves. The
// owner must call app.exit() itself when drain is complete.
event.preventDefault();
onBeforeQuit();
});
}
+1 -1
View File
@@ -1,2 +1,2 @@
export { createAutomationService, autoAssignSkillsToAgent } from "./automation/index";
export { createAutomationService } from "./automation/index";
export type { AutomationEventContext } from "./automation/index";
@@ -10,6 +10,7 @@
*/
import { z } from "zod";
import { approvalTypeEnum } from "@shared/types";
const messageChannelSchema = z.enum(["direct", "department", "company", "project", "incident"]);
const messagePrioritySchema = z.enum(["urgent", "normal", "low"]);
@@ -41,7 +42,7 @@ export const triggerHeartbeatConfigSchema = z.object({
});
export const createApprovalConfigSchema = z.object({
approvalType: z.string().optional(),
approvalType: approvalTypeEnum.optional(),
summary: z.string().optional(),
impact: z.string().optional(),
});
+12 -4
View File
@@ -3,7 +3,6 @@ import type { AutomationAction, DesktopEvent } from "@shared/types";
import { dispatchAgentMessage } from "../message-dispatch";
import { validateTaskCreation } from "../task-quality-gates";
import type { AutomationEventContext } from "./types";
import { isHeartbeatTaskTitle } from "../orchestration/heartbeat-task";
import { parseActionConfig, type AutomationActionConfigMap } from "./automation-action-schemas";
// Map trigger type → default wake trigger when automation fires a heartbeat.
@@ -116,8 +115,9 @@ export function executeAutomationAction(
const description = cfg.description ?? "";
const priority = cfg.priority ?? "medium";
const assigneeAgentId = cfg.assigneeAgentId ?? null;
const isHeartbeat = isHeartbeatTaskTitle(title);
const taskValidation = validateTaskCreation({ title, description }, isHeartbeat);
// Automation rules create user-facing tasks; heartbeat tasks are
// synthesized only by acquireHeartbeatTask, never via create_task.
const taskValidation = validateTaskCreation({ title, description }, false);
if (!taskValidation.valid) {
logger.warn(`[automation] Skipping create_task — validation failed: ${taskValidation.errors.join("; ")}`);
break;
@@ -231,7 +231,15 @@ export function executeAutomationAction(
// Keep the fallback title when the task no longer exists.
}
// Heartbeat tasks are internal system tasks — skip escalation messages to avoid flooding channels.
if (isHeartbeatTaskTitle(taskTitle)) {
let isHeartbeatTask = false;
if (ctx.taskId) {
try {
isHeartbeatTask = db.getTask(ctx.taskId).isHeartbeat === true;
} catch {
isHeartbeatTask = false;
}
}
if (isHeartbeatTask) {
logger.info(`[automation] Skipping escalation for heartbeat task "${taskTitle}"`);
break;
}
@@ -3,7 +3,6 @@ import type { AppDatabase } from "../database";
import type { NotificationOptions } from "../notification-service";
import { extractApprovalDocumentIds } from "@shared/approval-documents";
import { notifyHireApproved } from "../hire-hook";
import { autoAssignSkillsToAgent } from "./automation-skills";
import type { AutomationEventContext } from "./types";
import { normalizeWorkflowSteps } from "./automation-workflow";
@@ -95,140 +94,110 @@ export function handleApprovalResolved(
});
if (approval.type === "hire_agent" && decision === "approved" && approval.relatedAgentId) {
try {
const agent = db.getAgent(approval.relatedAgentId);
const agent = db.getAgent(approval.relatedAgentId);
if (agent.status === "idle") {
db.addActivity({
companyId,
actor: "system",
action: "agent.hired",
entityType: "agent",
entityId: agent.id,
detail: `${agent.name} hire approved and activated.`,
});
if (agent.status === "idle") {
db.addActivity({
companyId,
actor: "system",
action: "agent.hired",
entityType: "agent",
entityId: agent.id,
detail: `${agent.name} hire approved and activated.`,
});
const skillsAssigned = autoAssignSkillsToAgent(db, logger, companyId, agent.id);
if (skillsAssigned > 0) {
db.addActivity({
companyId,
actor: "system",
action: "skills.auto_assigned",
entityType: "agent",
entityId: agent.id,
detail: `Auto-assigned ${skillsAssigned} skill(s) to ${agent.name} based on role/department matching.`,
});
}
dispatchAutomation("hire_approved", {
companyId,
agentId: agent.id,
});
wakeAgentIfPossible(agent.id, companyId, "assignment");
dispatchAutomation("hire_approved", {
companyId,
agentId: agent.id,
});
wakeAgentIfPossible(agent.id, companyId, "assignment");
const ceo = db.getCeoForCompany(companyId);
if (ceo && ceo.id !== agent.id) {
wakeAgentIfPossible(ceo.id, companyId, "assignment");
}
publishDomainChanged();
const hireLocale = db.listSnapshot().locale;
notify({
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(() => {});
const ceo = db.getCeoForCompany(companyId);
if (ceo && ceo.id !== agent.id) {
wakeAgentIfPossible(ceo.id, companyId, "assignment");
}
} catch {
// Hire follow-up is best-effort; approval state is already committed in storage.
publishDomainChanged();
const hireLocale = db.listSnapshot().locale;
notify({
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(() => {});
}
}
if (approval.type === "deliverable_review") {
try {
// Update linked document statuses from payloadJson
const documentIds = extractApprovalDocumentIds(approval.payloadJson);
const docStatus = decision === "approved"
? "approved"
: decision === "rejected" || decision === "revision_requested"
? "draft"
: "in_review";
for (const docId of documentIds) {
db.updateDocumentStatus(docId, companyId, docStatus);
}
if (approval.relatedTaskId) {
const task = db.getTask(approval.relatedTaskId);
if (task.assigneeAgentId) {
try {
const agent = db.getAgent(task.assigneeAgentId);
if (!agent.heartbeatEnabled) {
db.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
}
} catch { /* agent lookup non-fatal */ }
}
if (decision === "approved") {
db.advanceTaskStatus(task.id, "done", "Board approved deliverable.");
handleTaskStatusChange(task.id, companyId, task.status, "done");
db.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." });
} else if (decision === "rejected") {
recordReviewFeedbackKnowledge(db, approval, decision);
db.advanceTaskStatus(task.id, "in_progress", `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`);
handleTaskStatusChange(task.id, companyId, task.status, "in_progress");
if (task.assigneeAgentId) {
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
}
} else if (decision === "revision_requested") {
db.advanceTaskStatus(task.id, "in_progress", `Board requested revisions: ${approval.decisionNote || "No reason given."}`);
handleTaskStatusChange(task.id, companyId, task.status, "in_progress");
recordReviewFeedbackKnowledge(db, approval, decision);
if (task.assigneeAgentId) {
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
}
}
} else if (approval.requestedByAgentId) {
// Document-only review (no task) — resume the producing agent
try {
const agent = db.getAgent(approval.requestedByAgentId);
if (!agent.heartbeatEnabled) {
db.setHeartbeat(approval.requestedByAgentId, true, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
}
} catch { /* agent lookup non-fatal */ }
if (decision !== "approved") {
wakeAgentIfPossible(approval.requestedByAgentId, companyId, "revision_needed");
}
}
publishDomainChanged();
} catch {
// Deliverable review follow-up is non-fatal — approval state is already committed.
const documentIds = extractApprovalDocumentIds(approval.payloadJson);
const docStatus = decision === "approved"
? "approved"
: decision === "rejected" || decision === "revision_requested"
? "draft"
: "in_review";
for (const docId of documentIds) {
db.updateDocumentStatus(docId, companyId, docStatus);
}
if (approval.relatedTaskId) {
const task = db.getTask(approval.relatedTaskId);
if (task.assigneeAgentId) {
const agent = db.getAgent(task.assigneeAgentId);
if (!agent.heartbeatEnabled) {
db.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
}
}
if (decision === "approved") {
db.advanceTaskStatus(task.id, "done", "Board approved deliverable.");
handleTaskStatusChange(task.id, companyId, task.status, "done");
db.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." });
} else if (decision === "rejected") {
recordReviewFeedbackKnowledge(db, approval, decision);
db.advanceTaskStatus(task.id, "in_progress", `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`);
handleTaskStatusChange(task.id, companyId, task.status, "in_progress");
if (task.assigneeAgentId) {
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
}
} else if (decision === "revision_requested") {
db.advanceTaskStatus(task.id, "in_progress", `Board requested revisions: ${approval.decisionNote || "No reason given."}`);
handleTaskStatusChange(task.id, companyId, task.status, "in_progress");
recordReviewFeedbackKnowledge(db, approval, decision);
if (task.assigneeAgentId) {
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
}
}
} else if (approval.requestedByAgentId) {
const agent = db.getAgent(approval.requestedByAgentId);
if (!agent.heartbeatEnabled) {
db.setHeartbeat(approval.requestedByAgentId, true, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
}
if (decision !== "approved") {
wakeAgentIfPossible(approval.requestedByAgentId, companyId, "revision_needed");
}
}
publishDomainChanged();
}
if (approval.type === "draft_review" || approval.type === "strategy_review") {
try {
const documentIds = extractApprovalDocumentIds(approval.payloadJson);
const docStatus = decision === "approved" ? "approved" : "draft";
for (const docId of documentIds) {
db.updateDocumentStatus(docId, companyId, docStatus);
}
publishDomainChanged();
} catch {
// Document checkpoint follow-up is non-fatal — approval state is already committed.
const documentIds = extractApprovalDocumentIds(approval.payloadJson);
const docStatus = decision === "approved" ? "approved" : "draft";
for (const docId of documentIds) {
db.updateDocumentStatus(docId, companyId, docStatus);
}
publishDomainChanged();
}
// Handle budget override approvals
@@ -3,7 +3,6 @@ import type { RunRecord, SectionId } from "@shared/types";
import type { NotificationOptions } from "../notification-service";
import type { AutomationEventContext, RunFinishedEvent } from "./types";
import { IMPORTANT_DOCUMENT_TYPES } from "./types";
import { isHeartbeatTaskTitle } from "../orchestration/heartbeat-task";
function localizeNotification(locale: string | null | undefined, en: string, zh: string): string {
return locale === "zh" ? zh : en;
@@ -225,11 +224,11 @@ export function processRunCompletion(
let effectiveStatus = task.status;
if (isSuccess && effectiveStatus === "in_progress") {
// Check if non-code task produced a deliverable before auto-advancing
// Delegate the task-type → deliverable-required decision to the pure
// helper so production and tests share a single source of truth.
const taskType = task.taskType ?? "general";
const needsDeliverable = taskType === "research" || taskType === "content";
if (needsDeliverable) {
if (needsDeliverableCheck(taskType)) {
const hasDeliverable = checkRunProducedDeliverable(db, run.id, run.companyId, task.id);
if (!hasDeliverable) {
// Don't auto-advance -- flag for manager review
@@ -243,7 +242,11 @@ export function processRunCompletion(
if (chain.length > 0) {
wakeAgentIfPossible(chain[0].id, run.companyId, "review_needed");
}
} catch { /* best effort */ }
} catch (error) {
deps.logger.warn(
`[automation] deliverable-review manager wake failed for agent ${run.agentId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
publishDomainChanged();
// Skip the normal in_review advance
} else {
@@ -257,7 +260,7 @@ export function processRunCompletion(
}
}
if (isSuccess && isHeartbeatTaskTitle(task.title) && effectiveStatus !== "done") {
if (isSuccess && task.isHeartbeat && effectiveStatus !== "done") {
db.advanceTaskStatus(task.id, "done", "Heartbeat check completed.");
handleTaskStatusChange(task.id, run.companyId, effectiveStatus, "done");
}
-127
View File
@@ -1,127 +0,0 @@
import type { AppDatabase } from "../database";
import { roleMatchesLeadershipKeywords } from "../agent-role-helpers";
import type { DepartmentId } from "@shared/types/enums";
const DEPARTMENT_SKILL_KEYWORDS: Record<DepartmentId, string[]> = {
engineering: ["code", "engineer", "develop", "program", "software", "api", "backend", "frontend", "full-stack", "fullstack", "devops", "infrastructure", "system", "architect", "debug", "test", "ci", "cd", "deploy", "docker", "kubernetes", "database", "sql", "git", "typescript", "javascript", "python", "rust", "go", "java", "react", "node", "css", "html", "web"],
product: ["product", "feature", "requirement", "roadmap", "user story", "ux", "ui", "design", "prototype", "wireframe", "specification", "prd"],
marketing: ["market", "content", "seo", "social media", "brand", "campaign", "analytics", "growth", "copywrite", "blog", "newsletter", "email"],
sales: ["sales", "crm", "lead", "pipeline", "deal", "revenue", "prospect", "outbound", "cold"],
hr: ["hr", "human resource", "hiring", "recruit", "onboard", "performance", "culture", "talent", "people"],
finance: ["finance", "budget", "accounting", "cost", "revenue", "profit", "spend", "invoice", "tax", "payroll"],
design: ["design", "ui", "ux", "visual", "figma", "sketch", "prototype", "illustration", "typography", "layout", "color", "icon"],
research: ["research", "analysis", "data", "study", "survey", "experiment", "insight", "benchmark", "report"],
operations: ["operations", "process", "workflow", "efficiency", "supply chain", "logistics", "coordination"],
customer_support: ["support", "customer", "ticket", "helpdesk", "troubleshoot", "faq", "documentation"],
legal: ["legal", "compliance", "contract", "regulation", "policy", "privacy", "ip", "intellectual property"],
executive: ["strategy", "leadership", "management", "executive", "ceo", "cto", "coo", "cfo", "cmo", "vp"],
};
function scoreSkillAgentMatch(
skill: { name: string; description: string | null; markdown: string },
agent: { role: string; department: string | null; title: string | null; capabilities: string },
): number {
const agentText = [
agent.role,
agent.department ?? "",
agent.title ?? "",
agent.capabilities,
].join(" ").toLowerCase();
const skillText = [
skill.name,
skill.description ?? "",
skill.markdown.slice(0, 500),
].join(" ").toLowerCase();
let score = 0;
const dept = (agent.department ?? "") as DepartmentId;
const deptKeywords = DEPARTMENT_SKILL_KEYWORDS[dept] ?? [];
for (const keyword of deptKeywords) {
if (skillText.includes(keyword)) {
score += 2;
}
}
const agentWords = agentText
.replace(/[^a-z0-9\s-]/g, " ")
.split(/\s+/)
.filter((w) => w.length >= 3);
for (const word of agentWords) {
if (skillText.includes(word)) {
score += 1;
}
}
// Executives need awareness of all domains. Use the shared leadership
// keyword list with word-boundary matching so "lead" does not match
// "cleaner" / "leaderboard" in agent role strings.
if (roleMatchesLeadershipKeywords(agent.role, agent.title)) {
score += 1;
}
return score;
}
export function autoAssignSkillsToAgent(
db: AppDatabase,
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void },
companyId: string,
agentId: string,
): number {
const agent = db.getAgent(agentId);
if (!agent || agent.companyId !== companyId) return 0;
const allSkills = db.listCompanySkills(companyId);
if (allSkills.length === 0) return 0;
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) => ({
skill,
score: scoreSkillAgentMatch(skill, agent),
}))
.sort((a, b) => b.score - a.score);
const MIN_SCORE = 3;
for (const { skill, score } of scored) {
if (score < MIN_SCORE) continue;
try {
db.assignSkillToAgent(agentId, skill.id);
assigned++;
logger.info(
`[skill-autoassign] Assigned "${skill.name}" to ${agent.name} (${agent.role}) — score ${score}`,
);
} catch (err) {
logger.warn(
`[skill-autoassign] Failed to assign "${skill.name}" to ${agent.name}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (assigned === 0) {
logger.info(
`[skill-autoassign] No matching skills for ${agent.name} (${agent.role}); agent will run without skills until manually assigned`,
);
}
return assigned;
}
-1
View File
@@ -13,7 +13,6 @@ import { handleTaskStatusChange, handleTaskCreated } from "./automation-handlers
import type { HandlerDeps } from "./automation-handlers";
export type { AutomationEventContext } from "./types";
export { autoAssignSkillsToAgent } from "./automation-skills";
export function createAutomationService({
db,
+8
View File
@@ -106,6 +106,14 @@ export function prepareServices(deps: PrepareServicesDeps): ServiceGraphHandles
onRunCompleted: (runId, taskId, agentId, companyId, status, summary, errorMessage) =>
runHousekeeping.onRunCompleted(runId, taskId, agentId, companyId, status, summary, errorMessage),
checkpointEngine,
// Lazy forwarder — ipcSupport is constructed below but only invoked
// inside serviceGraph.init(), which runs after that point.
registerHandle: (channel, handler) => {
if (!ipcSupport) {
throw new Error("IPC support is not initialized.");
}
ipcSupport.registerHandle(channel, handler);
},
});
ipcSupport = createIpcSupport({
isDev: deps.isDev,
@@ -35,7 +35,11 @@ export function recoverScheduledPosts(deps: RecoverScheduledPostsDeps): Set<Retu
}, delay);
timers.add(timer);
deps.logger.info(`[bootstrap] Re-registered scheduled post timer: action ${action.id} in ${Math.round(delay / 1000)}s`);
} catch { /* ignore malformed payload */ }
} catch (error) {
deps.logger.warn(
`[bootstrap] malformed scheduled-post payload for action ${action.id}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
} catch (error) {
+6 -1
View File
@@ -1,3 +1,4 @@
import log from "electron-log/main.js";
import type { AgentApiServer } from "../api-server";
import type { AppDatabase } from "../database";
import type { DesktopEvent, SectionId } from "@shared/types";
@@ -68,7 +69,11 @@ export function wireApiServer(deps: WireApiServerDeps): AgentApiServer {
try {
const task = deps.db.getTask(taskId);
deps.checkpointEngine.onTaskStarted(taskId, companyId, task.assigneeAgentId);
} catch { /* task may not exist */ }
} catch (error) {
log.warn(
`[wire-api-server] checkpointEngine.onTaskStarted failed for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
};
const onApprovalResolved = (approvalId: string, companyId: string, decision: string) => {
+5 -1
View File
@@ -313,7 +313,11 @@ export async function runAction(
try {
const image: NativeImage = await win.webContents.capturePage();
if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG());
} catch { /* ignore */ }
} catch (screenshotError) {
log.warn(
`[browser] failure screenshot capture failed for action ${action.id}: ${screenshotError instanceof Error ? screenshotError.message : String(screenshotError)}`,
);
}
if (!win.isDestroyed()) win.close();
+2 -14
View File
@@ -44,10 +44,7 @@ export function createCheckpointEngine(deps: CheckpointDeps) {
if (mode !== "ai_cofounder") return null;
const task = db.getTask(taskId);
const taskType = db.queryAll<{ taskType: string }>(
"SELECT task_type as taskType FROM tasks WHERE id = ? LIMIT 1",
taskId,
)[0]?.taskType;
const taskType = db.getTaskType(taskId);
// Only create plan checkpoints for non-code task types
if (!taskType || !CHECKPOINT_TASK_TYPES.has(taskType)) return null;
@@ -136,16 +133,7 @@ export function createCheckpointEngine(deps: CheckpointDeps) {
}
// Check for pending approvals related to this agent's tasks
const pendingApprovals = db.queryAll<{ id: string }>(
`SELECT a.id FROM approvals a
JOIN tasks t ON a.related_task_id = t.id
WHERE a.company_id = ? AND a.state = 'pending'
AND t.assignee_agent_id = ?
AND a.type IN ('plan_review','scope_review','draft_review','strategy_review','deliverable_review','exception_review')
LIMIT 1`,
companyId, agentId,
);
return pendingApprovals.length > 0;
return db.hasPendingReviewApprovalForAgent(companyId, agentId);
} catch (err) {
logger.error(`[checkpoint] shouldPauseHeartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
return false;
+6 -7
View File
@@ -7,11 +7,10 @@
* token in the `credentials` table, and passes it as the API key when
* invoking pi-ai.
*
* The `oauthId` surfaced here is the AgentCompany internal id that the
* catalog test pins (`anthropic` / `openai-codex` / `github-copilot` /
* `gemini-cli` / `antigravity`). It is mapped to the pi-ai provider id
* when resolving the actual OAuth flow — e.g. `gemini-cli` →
* `google-gemini-cli`, `antigravity` → `google-antigravity`.
* The `oauthId` surfaced here matches pi-ai's five registered provider ids
* verbatim (`anthropic` / `openai-codex` / `github-copilot` /
* `google-gemini-cli` / `google-antigravity`) and is passed straight to
* `getOAuthProvider`.
*/
import type { ConnectorDefinition } from "./connector-definitions";
@@ -58,7 +57,7 @@ export const OAUTH_CONNECTORS: ConnectorDefinition[] = [
description:
"Google Gemini models via Cloud Code Assist OAuth. Free tier works with any Google account (rate-limited).",
defaultModel: "gemini-2.5-pro",
auth: { type: "oauth", oauthId: "gemini-cli" },
auth: { type: "oauth", oauthId: "google-gemini-cli" },
notes:
"For paid Cloud Code Assist, set GOOGLE_CLOUD_PROJECT. Google-gemini-cli OAuth provider id in pi-ai.",
},
@@ -69,7 +68,7 @@ export const OAUTH_CONNECTORS: ConnectorDefinition[] = [
description:
"Google Antigravity sandbox with Gemini 3, Claude, and GPT-OSS models. Free with any Google account.",
defaultModel: "claude-sonnet-4-5",
auth: { type: "oauth", oauthId: "antigravity" },
auth: { type: "oauth", oauthId: "google-antigravity" },
notes: "Maps to pi-ai OAuth provider id `google-antigravity`.",
},
];
+3 -5
View File
@@ -30,16 +30,14 @@ import { CLOUD_CONNECTORS } from "./connector-definitions-data-cloud";
/**
* OAuth provider id recognised by the AgentCompany credential layer. These
* map 1:1 to pi-ai's five built-in OAuth handlers, with the Google handlers
* shortened from `google-gemini-cli` / `google-antigravity` to `gemini-cli` /
* `antigravity` in the UI.
* match pi-ai's five built-in OAuth handler ids verbatim.
*/
export type PiOAuthProviderId =
| "anthropic"
| "openai-codex"
| "github-copilot"
| "gemini-cli"
| "antigravity";
| "google-gemini-cli"
| "google-antigravity";
export type ConnectorAuthKind = "oauth" | "api_key" | "aws" | "azure" | "gcp_adc";
@@ -57,6 +57,17 @@ export class CredentialResolver {
if (stored.value.source === "literal") {
return { apiKey: stored.value.value };
}
// Only the connector's own declared envKey may be read from process.env.
// Arbitrary env var names would let a malicious payload exfil secrets
// like CSC_KEY_PASSWORD or AWS_SECRET_ACCESS_KEY by routing them to a
// provider as the "api key".
if (stored.value.value !== auth.envKey) {
throw new CredentialError(
"CREDENTIAL_INVALID",
connectorId,
`Env var ${stored.value.value} is not permitted for connector ${connectorId}.`,
);
}
const fromEnv = process.env[stored.value.value];
if (!fromEnv) throw new CredentialError("CREDENTIAL_MISSING", connectorId, `Env var ${stored.value.value} is not set.`);
return { apiKey: fromEnv };
+15 -4
View File
@@ -69,7 +69,7 @@ export class AppDatabaseBase {
public readonly sessionEntries: ReturnType<typeof buildSessionEntryMethods>;
public readonly credentialsTable: ReturnType<typeof buildCredentialMethods>;
constructor(rootDir: string) {
constructor(rootDir: string, options: { isPackaged?: boolean } = {}) {
this.profileDir = rootDir;
this.dbPath = join(rootDir, "profile.sqlite");
this.logsDir = join(rootDir, "logs");
@@ -79,7 +79,7 @@ export class AppDatabaseBase {
mkdirSync(this.logsDir, { recursive: true });
mkdirSync(this.artifactsDir, { recursive: true });
mkdirSync(this.backupsDir, { recursive: true });
this.vault = new SecretVault(rootDir);
this.vault = new SecretVault(rootDir, { isPackaged: options.isPackaged });
this.db = this.openConnection();
const getDb = () => this.db;
@@ -241,6 +241,12 @@ export class AppDatabaseBase {
getHeartbeatPolicy(agentId: string) { return this.agents.getHeartbeatPolicy(agentId); }
setHeartbeatPolicy(agentId: string, companyId: string, policy: { enabled?: boolean; intervalSec?: number; cooldownSec?: number; maxConcurrentRuns?: number; wakeOnDemand?: boolean }) { return this.agents.setHeartbeatPolicy(agentId, companyId, policy); }
countActiveRunsForAgent(agentId: string, companyId: string) { return this.agents.countActiveRunsForAgent(agentId, companyId); }
listDepartmentAgentIds(companyId: string, department: string) { return this.agents.listDepartmentAgentIds(companyId, department); }
listTopLevelAgentIds(companyId: string) { return this.agents.listTopLevelAgentIds(companyId); }
listIncidentPingableAgentIds(companyId: string) { return this.agents.listIncidentPingableAgentIds(companyId); }
findAgentName(agentId: string) { return this.agents.findAgentName(agentId); }
listAgentNamesByIds(companyId: string, agentIds: readonly string[]) { return this.agents.listAgentNamesByIds(companyId, agentIds); }
listActiveAgentIdsFromIds(companyId: string, agentIds: readonly string[]) { return this.agents.listActiveAgentIdsFromIds(companyId, agentIds); }
// ─── Tasks ─────────────────────────────────────────────────────────────────
@@ -258,8 +264,8 @@ export class AppDatabaseBase {
checkoutTask(taskId: string, runId: string) { return this.tasks.checkoutTask(taskId, runId); }
releaseTaskLock(taskId: string, runId: string) { return this.tasks.releaseTaskLock(taskId, runId); }
renewTaskLease(taskId: string, runId: string) { return this.tasks.renewTaskLease(taskId, runId); }
listAgentTasks(agentId: string, companyId: string, statuses: string[]) { return this.tasks.listAgentTasks(agentId, companyId, statuses); }
listTasksByStatuses(companyId: string, statuses: string[]) { return this.tasks.listTasksByStatuses(companyId, statuses); }
listAgentTasks(agentId: string, companyId: string, statuses: string[], options?: { includeHeartbeat?: boolean }) { return this.tasks.listAgentTasks(agentId, companyId, statuses, options); }
listTasksByStatuses(companyId: string, statuses: string[], options?: { includeHeartbeat?: boolean }) { return this.tasks.listTasksByStatuses(companyId, statuses, options); }
listTasksForStandup(companyId: string) { return this.tasks.listTasksForStandup(companyId); }
listAssigneeTasksForStandup(companyId: string, agentId: string) { return this.tasks.listAssigneeTasksForStandup(companyId, agentId); }
listTasksForAssignees(companyId: string, assigneeIds: string[]) { return this.tasks.listTasksForAssignees(companyId, assigneeIds); }
@@ -282,6 +288,9 @@ export class AppDatabaseBase {
dismissInterruptedTask(taskId: string) { return this.tasks.dismissInterruptedTask(taskId); }
markInterruptedTaskFailed(taskId: string) { return this.tasks.markInterruptedTaskFailed(taskId); }
setTaskType(taskId: string, taskType: string) { return this.tasks.setTaskType(taskId, taskType); }
listProjectTaskAssigneeIds(projectId: string, companyId: string) { return this.tasks.listProjectTaskAssigneeIds(projectId, companyId); }
listReviewTasksForSweep(companyId: string) { return this.tasks.listReviewTasksForSweep(companyId); }
getTaskType(taskId: string) { return this.tasks.getTaskType(taskId); }
// ─── Runs ──────────────────────────────────────────────────────────────────
@@ -322,6 +331,8 @@ export class AppDatabaseBase {
listAgentResolvedApprovals(companyId: string, agentId: string, limit?: number) { return this.approvalsMod.listAgentResolvedApprovals(companyId, agentId, limit); }
listPendingHireApprovalPayloads(companyId: string) { return this.approvalsMod.listPendingHireApprovalPayloads(companyId); }
listApprovalsForApi(companyId: string, filters: { requestedByAgentId?: string; relatedTaskId?: string; statuses?: string[] | null }) { return this.approvalsMod.listApprovalsForApi(companyId, filters); }
findPendingApprovalForTask(companyId: string, taskId: string) { return this.approvalsMod.findPendingApprovalForTask(companyId, taskId); }
hasPendingReviewApprovalForAgent(companyId: string, agentId: string) { return this.approvalsMod.hasPendingReviewApprovalForAgent(companyId, agentId); }
// ─── Workspaces ────────────────────────────────────────────────────────────
+7 -1
View File
@@ -170,7 +170,13 @@ export class AppDatabase extends AppDatabaseBase {
listSubscriptions(threadId: string) { return this.threads.listSubscriptions(threadId); }
listSubscribedThreads(subscriberId: string, companyId: string) { return this.threads.listSubscribedThreads(subscriberId, companyId); }
// ─── Public query ──────────────────────────────────────────────────────────
// ─── Internal raw-SQL escape hatch ────────────────────────────────────────
//
// Not part of the public API. New callers MUST add a typed method on the
// appropriate `database/<module>.ts` and route through there. The existing
// call sites remain only to avoid a repo-wide migration; see
// `.planning/review2/02-persistence-ipc.md` DB-001 for the migration list.
/** @internal */
queryAll<T>(sql: string, ...params: SQLInputValue[]): T[] { return this.readAll<T>(sql, ...params); }
}
+75
View File
@@ -408,5 +408,80 @@ export function buildAgentHierarchyMethods(
)
.run(merged.enabled ? 1 : 0, merged.intervalSec, policyJson, ts, agentId, companyId);
},
/**
* Returns ids of non-terminated agents in a department. Used by
* `resolveMessageWakeTargets` to fan out "department" channel messages.
*/
listDepartmentAgentIds(companyId: string, department: string): string[] {
return readAll<{ id: string }>(
"select id from agents where company_id = ? and department = ? and status != 'terminated'",
companyId,
department,
).map((row) => row.id);
},
/**
* Returns ids of non-terminated agents that have no manager (top of the
* reporting tree). Used by `resolveMessageWakeTargets` for "company"
* channel broadcast.
*/
listTopLevelAgentIds(companyId: string): string[] {
return readAll<{ id: string }>(
"select id from agents where company_id = ? and reports_to is null and status != 'terminated'",
companyId,
).map((row) => row.id);
},
/**
* Returns ids of agents that can receive incident pings: everybody not
* terminated or still pending approval.
*/
listIncidentPingableAgentIds(companyId: string): string[] {
return readAll<{ id: string }>(
"select id from agents where company_id = ? and status not in ('terminated', 'pending_approval')",
companyId,
).map((row) => row.id);
},
/**
* Returns the agent's display name, or null if the agent no longer
* exists. Used for notification subject lines.
*/
findAgentName(agentId: string): string | null {
const row = getDb()
.prepare("select name from agents where id = ? limit 1")
.get(agentId) as { name: string } | undefined;
return row?.name ?? null;
},
/**
* Returns (id, name) pairs for the given ids, scoped to company. Used
* for batched name resolution when fanning out message wake targets.
*/
listAgentNamesByIds(companyId: string, agentIds: readonly string[]): Array<{ id: string; name: string }> {
if (agentIds.length === 0) return [];
const placeholders = agentIds.map(() => "?").join(",");
return readAll<{ id: string; name: string }>(
`select id, name from agents where id in (${placeholders}) and company_id = ?`,
...agentIds,
companyId,
);
},
/**
* Returns ids of active agents with the given ids, filtered to the
* company scope. Used by project-channel wake targeting to drop
* terminated members before pinging.
*/
listActiveAgentIdsFromIds(companyId: string, agentIds: readonly string[]): string[] {
if (agentIds.length === 0) return [];
const placeholders = agentIds.map(() => "?").join(",");
return readAll<{ id: string }>(
`select id from agents where id in (${placeholders}) and company_id = ? and status != 'terminated'`,
...agentIds,
companyId,
).map((row) => row.id);
},
};
}
+41 -1
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
import log from "electron-log/main.js";
import type { ApprovalRecord } from "@shared/types";
import type { ReadAll, Transaction } from "./helpers";
import { normalizeOptionalId, nowIso, parseJson } from "./helpers";
@@ -54,7 +55,11 @@ export function buildApprovalMethods(
try {
const payload = JSON.parse(input.payloadJson) as { agentId?: string };
relatedAgentId = payload.agentId ?? null;
} catch { /* ignore malformed payload */ }
} catch (error) {
log.warn(
`[db/approvals] malformed payloadJson on hire_agent request for company ${input.companyId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
if (input.type === "hire_agent") {
if (!relatedAgentId) {
@@ -346,5 +351,40 @@ export function buildApprovalMethods(
}
});
},
/**
* Returns the first pending approval linked to a task (id + type),
* or null when none exists. Used by task-quality-gates to decide
* whether an in-review task still has a blocking approval.
*/
findPendingApprovalForTask(companyId: string, taskId: string): { id: string; type: string } | null {
const row = getDb()
.prepare(
`SELECT id, type FROM approvals
WHERE company_id = ? AND related_task_id = ? AND state = 'pending'
LIMIT 1`,
)
.get(companyId, taskId) as { id: string; type: string } | undefined;
return row ?? null;
},
/**
* Returns true when the given agent has a pending review-type approval
* against one of their tasks. Used by checkpoint-engine to decide
* whether heartbeat should pause in co-founder mode.
*/
hasPendingReviewApprovalForAgent(companyId: string, agentId: string): boolean {
const row = getDb()
.prepare(
`SELECT 1 as hit FROM approvals a
JOIN tasks t ON a.related_task_id = t.id
WHERE a.company_id = ? AND a.state = 'pending'
AND t.assignee_agent_id = ?
AND a.type IN ('plan_review','scope_review','draft_review','strategy_review','deliverable_review','exception_review')
LIMIT 1`,
)
.get(companyId, agentId) as { hit: number } | undefined;
return row !== undefined;
},
};
}
-2
View File
@@ -32,8 +32,6 @@ export function buildBackupMethods(
backupDatabase(deps.getDb(), join(targetDir, "profile.sqlite")),
cp(deps.logsDir, join(targetDir, "logs"), { recursive: true, force: true }),
cp(deps.artifactsDir, join(targetDir, "artifacts"), { recursive: true, force: true }),
copyIfExists(join(deps.profileDir, "vault.key"), join(targetDir, "vault.key")),
copyIfExists(join(deps.profileDir, "agent-api-signing.key"), join(targetDir, "agent-api-signing.key")),
copyIfExists(`${deps.dbPath}-wal`, join(targetDir, "profile.sqlite-wal")),
copyIfExists(`${deps.dbPath}-shm`, join(targetDir, "profile.sqlite-shm")),
];
+2 -1
View File
@@ -49,11 +49,12 @@ export function buildEntityCrudMethods(
getTask(id: string): TaskRecord {
const row = getDb()
.prepare(
"select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where id = ?",
"select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, is_heartbeat as isHeartbeat, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where id = ?",
)
.get(id) as TaskRecord | undefined;
if (!row) throw new Error(`Task not found: ${id}`);
row.requiresUserReview = Boolean(row.requiresUserReview);
row.isHeartbeat = Boolean(row.isHeartbeat);
row.startedAt = row.startedAt ?? null;
row.completedAt = row.completedAt ?? null;
row.cancelledAt = row.cancelledAt ?? null;
+3 -2
View File
@@ -279,14 +279,15 @@ export function buildRunMethods(
getInterruptedRunsForRecovery(): Array<{
runId: string; taskId: string; taskTitle: string; agentId: string;
agentName: string; connectorId: string; interruptedAt: string;
agentName: string; companyId: string; connectorId: string; interruptedAt: string;
}> {
return readAll<{
runId: string; taskId: string; taskTitle: string; agentId: string;
agentName: string; connectorId: string; interruptedAt: string;
agentName: string; companyId: string; connectorId: string; interruptedAt: string;
}>(
`select r.id as runId, r.task_id as taskId, t.title as taskTitle,
r.agent_id as agentId, a.name as agentName,
r.company_id as companyId,
r.connector_id as connectorId, r.finished_at as interruptedAt
from runs r
join tasks t on t.id = r.task_id
+15
View File
@@ -40,6 +40,7 @@ export function ensureOptionalColumns(db: DatabaseSync) {
addColumnIfMissing("tasks", "checkout_run_id", "alter table tasks add column checkout_run_id text");
addColumnIfMissing("tasks", "execution_locked_at", "alter table tasks add column execution_locked_at text");
addColumnIfMissing("tasks", "active_session_id", "alter table tasks add column active_session_id text");
addColumnIfMissing("tasks", "is_heartbeat", "alter table tasks add column is_heartbeat integer not null default 0");
addColumnIfMissing("cost_entries", "project_id", "alter table cost_entries add column project_id text");
db.exec(`
create table if not exists task_comments (id text primary key, company_id text not null, task_id text not null, author_agent_id text, author_name text not null, body text not null, created_at text not null);
@@ -455,6 +456,20 @@ export function runMigrations(
}
},
},
{
// Explicit flag for synthesized heartbeat tasks. Replaces title-prefix
// sniffing (`title LIKE '[Heartbeat]%'`) across queries, filters, and
// automation gates. Backfills from existing `[Heartbeat]` titles so the
// new column agrees with historical data on first boot.
version: 33, name: "add_tasks_is_heartbeat",
fn: () => {
if (!columnExists(db, "tasks", "is_heartbeat")) {
db.prepare("ALTER TABLE tasks ADD COLUMN is_heartbeat INTEGER NOT NULL DEFAULT 0").run();
}
db.prepare("UPDATE tasks SET is_heartbeat = 1 WHERE is_heartbeat = 0 AND title LIKE '[Heartbeat]%'").run();
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_is_heartbeat ON tasks(is_heartbeat)");
},
},
{
// pi-mono session transcripts + credential vault. Idempotent; safe to
// re-apply on fresh DBs or DBs that already have the tables.
+10 -2
View File
@@ -1,6 +1,7 @@
import { existsSync, lstatSync, realpathSync, unlinkSync } from "node:fs";
import { extname, resolve, sep } from "node:path";
import type { DatabaseSync } from "node:sqlite";
import log from "electron-log/main.js";
import type { ReadAll } from "./helpers";
import { nowIso } from "./helpers";
@@ -14,7 +15,10 @@ function isWithinOrEqual(targetPath: string, rootPath: string) {
function safeRealpath(targetPath: string) {
try {
return realpathSync(targetPath);
} catch {
} catch (error) {
log.debug(
`[db/settings] realpathSync fallback for ${targetPath}: ${error instanceof Error ? error.message : String(error)}`,
);
return resolve(targetPath);
}
}
@@ -113,7 +117,11 @@ export function buildSettingsMethods(
if (run.logPath) {
try {
unlinkSync(run.logPath);
} catch { /* Log files may already be gone */ }
} catch (error) {
log.warn(
`[db/settings] purgeExpiredRunLogs: unlink failed for run ${run.id} (${run.logPath}): ${error instanceof Error ? error.message : String(error)}`,
);
}
}
getDb()
.prepare("update runs set log_path = null, updated_at = ? where id = ?")
+2 -2
View File
@@ -75,9 +75,9 @@ export function buildSnapshotMethods(
...companyArgs,
),
tasks: readAll<TaskRecord>(
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc limit 2000`,
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, is_heartbeat as isHeartbeat, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere ? `${companyWhere} and is_heartbeat = 0` : " where is_heartbeat = 0"} order by created_at desc limit 2000`,
...companyArgs,
).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), startedAt: record.startedAt ?? null, completedAt: record.completedAt ?? null, cancelledAt: record.cancelledAt ?? null })) as TaskRecord[],
).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), isHeartbeat: toBoolean(record.isHeartbeat), startedAt: record.startedAt ?? null, completedAt: record.completedAt ?? null, cancelledAt: record.cancelledAt ?? null })) as TaskRecord[],
approvals: readAll<ApprovalRecord>(
`select id, company_id as companyId, related_task_id as relatedTaskId, related_agent_id as relatedAgentId, requested_by_agent_id as requestedByAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals${companyWhere} order by created_at desc limit 1000`,
...companyArgs,
+82 -13
View File
@@ -33,6 +33,7 @@ export type TaskListByStatusesRow = {
sessionDisplayId: string | null;
costUsd: number | null;
requiresUserReview: boolean;
isHeartbeat: boolean;
startedAt: string | null;
completedAt: string | null;
cancelledAt: string | null;
@@ -168,12 +169,24 @@ export function buildTaskCrudMethods(
});
},
listAgentTasks(agentId: string, companyId: string, statuses: string[]): TaskRecord[] {
/**
* Returns actionable real work for an agent. Heartbeat tasks are
* excluded by default since they are synthesized wake-up placeholders;
* pass `{ includeHeartbeat: true }` to include them (heartbeat-loop
* callers that already filter by `is_heartbeat` do not need this).
*/
listAgentTasks(
agentId: string,
companyId: string,
statuses: string[],
options: { includeHeartbeat?: boolean } = {},
): TaskRecord[] {
const placeholders = statuses.map(() => "?").join(",");
const heartbeatClause = options.includeHeartbeat ? "" : " and is_heartbeat = 0";
return readAll<TaskRecord>(
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, task_type as taskType, created_at as createdAt, updated_at as updatedAt
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, task_type as taskType, is_heartbeat as isHeartbeat, created_at as createdAt, updated_at as updatedAt
from tasks
where company_id = ? and assignee_agent_id = ? and status in (${placeholders})
where company_id = ? and assignee_agent_id = ? and status in (${placeholders})${heartbeatClause}
order by
case status
when 'in_progress' then 0
@@ -187,20 +200,21 @@ export function buildTaskCrudMethods(
companyId,
agentId,
...statuses,
);
).map((t) => ({ ...t, isHeartbeat: Boolean(t.isHeartbeat) }));
},
/**
* Returns the slim task projection (id, title, status, priority) for a
* single assignee, ordered by created_at desc. Used by the alternate
* me/standup builder in routes-agents-standup.ts.
* me/standup builder in routes-agents-standup.ts. Heartbeat tasks are
* excluded so UI aggregations do not surface synthetic wake tasks.
*/
listAssigneeTasksForStandup(
companyId: string,
agentId: string,
): Array<{ id: string; title: string; status: string; priority: string }> {
return readAll(
"select id, title, status, priority from tasks where company_id = ? and assignee_agent_id = ? order by created_at desc",
"select id, title, status, priority from tasks where company_id = ? and assignee_agent_id = ? and is_heartbeat = 0 order by created_at desc",
companyId,
agentId,
);
@@ -209,7 +223,8 @@ export function buildTaskCrudMethods(
/**
* Returns the slim task projection for a set of assignees. Safe on
* an empty assignee list (returns []). Used by the alternate
* me/standup builder to fetch reports' tasks in one query.
* me/standup builder to fetch reports' tasks in one query. Heartbeat
* tasks are excluded.
*/
listTasksForAssignees(
companyId: string,
@@ -224,7 +239,7 @@ export function buildTaskCrudMethods(
if (assigneeIds.length === 0) return [];
const placeholders = assigneeIds.map(() => "?").join(",");
return readAll(
`select id, title, status, priority, assignee_agent_id as assigneeAgentId from tasks where company_id = ? and assignee_agent_id in (${placeholders}) order by created_at desc`,
`select id, title, status, priority, assignee_agent_id as assigneeAgentId from tasks where company_id = ? and assignee_agent_id in (${placeholders}) and is_heartbeat = 0 order by created_at desc`,
companyId,
...assigneeIds,
);
@@ -232,7 +247,7 @@ export function buildTaskCrudMethods(
/**
* Returns the slim task projection used by the me/standup aggregation.
* Ordered by created_at desc.
* Ordered by created_at desc. Heartbeat tasks are excluded.
*/
listTasksForStandup(companyId: string): Array<{
id: string;
@@ -243,23 +258,32 @@ export function buildTaskCrudMethods(
priority: string;
}> {
return readAll(
"select id, company_id as companyId, title, assignee_agent_id as assigneeAgentId, status, priority from tasks where company_id = ? order by created_at desc",
"select id, company_id as companyId, title, assignee_agent_id as assigneeAgentId, status, priority from tasks where company_id = ? and is_heartbeat = 0 order by created_at desc",
companyId,
);
},
/**
* Returns tasks filtered by status. Heartbeat tasks are excluded by
* default — they are synthesized wake placeholders and should not
* appear in the user-facing issue list. Pass `{ includeHeartbeat: true }`
* to include them.
*/
listTasksByStatuses(
companyId: string,
statuses: string[],
options: { includeHeartbeat?: boolean } = {},
): TaskListByStatusesRow[] {
const placeholders = statuses.map(() => "?").join(", ");
return readAll<TaskListByStatusesRow & { requiresUserReview: number | boolean }>(
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where company_id = ? and status in (${placeholders}) order by created_at desc`,
const heartbeatClause = options.includeHeartbeat ? "" : " and is_heartbeat = 0";
return readAll<TaskListByStatusesRow & { requiresUserReview: number | boolean; isHeartbeat: number | boolean }>(
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, is_heartbeat as isHeartbeat, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where company_id = ? and status in (${placeholders})${heartbeatClause} order by created_at desc`,
companyId,
...statuses,
).map((t) => ({
...t,
requiresUserReview: Boolean(t.requiresUserReview),
isHeartbeat: Boolean(t.isHeartbeat),
startedAt: t.startedAt ?? null,
completedAt: t.completedAt ?? null,
cancelledAt: t.cancelledAt ?? null,
@@ -269,7 +293,7 @@ export function buildTaskCrudMethods(
countActiveTasksForCompany(companyId: string): number {
const row = getDb()
.prepare(
"select count(*) as cnt from tasks where company_id = ? and status not in ('done', 'cancelled') and title not like '[Heartbeat]%'",
"select count(*) as cnt from tasks where company_id = ? and status not in ('done', 'cancelled') and is_heartbeat = 0",
)
.get(companyId) as { cnt: number };
return row.cnt;
@@ -518,5 +542,50 @@ export function buildTaskCrudMethods(
.run(ts, taskId);
},
/**
* Returns the set of distinct assignee agent ids on tasks in a project,
* excluding unassigned tasks. Used by message-dispatch to fan out wake
* targets for a project channel.
*/
listProjectTaskAssigneeIds(projectId: string, companyId: string): string[] {
const rows = readAll<{ assigneeAgentId: string | null }>(
"select distinct assignee_agent_id as assigneeAgentId from tasks where project_id = ? and company_id = ? and assignee_agent_id is not null",
projectId,
companyId,
);
return rows.map((r) => r.assigneeAgentId).filter((id): id is string => id !== null);
},
/**
* Returns assignee agent ids for a set of task ids scoped to a company.
* Used by task-quality-gates sweep to find reviewers of in-review tasks
* that have exceeded the review timeout.
*/
listReviewTasksForSweep(companyId: string): Array<{
id: string;
assigneeAgentId: string | null;
updatedAt: string;
title: string;
}> {
return readAll(
`SELECT id, assignee_agent_id as assigneeAgentId, updated_at as updatedAt, title
FROM tasks
WHERE company_id = ? AND status = 'in_review'`,
companyId,
);
},
/**
* Returns `task_type` for a single task id, or null when the task is
* missing. Used by checkpoint-engine to branch on task type without
* loading the full row.
*/
getTaskType(taskId: string): string | null {
const row = getDb()
.prepare("SELECT task_type as taskType FROM tasks WHERE id = ? LIMIT 1")
.get(taskId) as { taskType: string | null } | undefined;
return row?.taskType ?? null;
},
};
}
+14 -10
View File
@@ -111,9 +111,10 @@ export function buildTaskStatusMethods(
status, priority, active_run_id as activeRunId,
session_json as sessionJson, session_display_id as sessionDisplayId,
cost_usd as costUsd, requires_user_review as requiresUserReview,
is_heartbeat as isHeartbeat,
created_at as createdAt, updated_at as updatedAt
FROM tasks
WHERE company_id = ? AND assignee_agent_id = ? AND title LIKE '[Heartbeat]%'
WHERE company_id = ? AND assignee_agent_id = ? AND is_heartbeat = 1
AND status IN ('done', 'in_review')
ORDER BY updated_at DESC, rowid DESC
LIMIT 1`,
@@ -121,6 +122,7 @@ export function buildTaskStatusMethods(
.get(companyId, agentId) as TaskRecord | undefined;
if (row) {
row.requiresUserReview = Boolean(row.requiresUserReview);
row.isHeartbeat = Boolean(row.isHeartbeat);
}
return row ?? null;
},
@@ -141,7 +143,7 @@ export function buildTaskStatusMethods(
const claimable = db
.prepare(
`SELECT id, status FROM tasks
WHERE company_id = ? AND assignee_agent_id = ? AND title LIKE '[Heartbeat]%'
WHERE company_id = ? AND assignee_agent_id = ? AND is_heartbeat = 1
AND status IN ('todo', 'backlog', 'done', 'in_review')
AND checkout_run_id IS NULL
ORDER BY
@@ -172,7 +174,7 @@ export function buildTaskStatusMethods(
const inflight = db
.prepare(
`SELECT 1 FROM tasks
WHERE company_id = ? AND assignee_agent_id = ? AND title LIKE '[Heartbeat]%'
WHERE company_id = ? AND assignee_agent_id = ? AND is_heartbeat = 1
AND status NOT IN ('done', 'cancelled')
LIMIT 1`,
)
@@ -184,9 +186,9 @@ export function buildTaskStatusMethods(
`INSERT INTO tasks
(id, company_id, project_id, goal_id, parent_id, title, description,
assignee_agent_id, workspace_id, status, priority,
checkout_run_id, execution_locked_at, started_at,
checkout_run_id, execution_locked_at, started_at, is_heartbeat,
created_at, updated_at)
VALUES (?, ?, NULL, NULL, NULL, ?, ?, ?, ?, 'in_progress', ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, NULL, NULL, NULL, ?, ?, ?, ?, 'in_progress', ?, ?, ?, ?, 1, ?, ?)`,
).run(
taskId,
input.companyId,
@@ -211,7 +213,7 @@ export function buildTaskStatusMethods(
status, priority, active_run_id as activeRunId,
session_json as sessionJson, session_display_id as sessionDisplayId,
cost_usd as costUsd, requires_user_review as requiresUserReview,
task_type as taskType,
is_heartbeat as isHeartbeat, task_type as taskType,
started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt,
created_at as createdAt, updated_at as updatedAt
FROM tasks WHERE id = ?`,
@@ -219,6 +221,7 @@ export function buildTaskStatusMethods(
.get(taskId) as TaskRecord | undefined;
if (!row) return null;
row.requiresUserReview = Boolean(row.requiresUserReview);
row.isHeartbeat = Boolean(row.isHeartbeat);
row.startedAt = row.startedAt ?? null;
row.completedAt = row.completedAt ?? null;
row.cancelledAt = row.cancelledAt ?? null;
@@ -231,13 +234,13 @@ export function buildTaskStatusMethods(
const result = getDb()
.prepare(
`UPDATE tasks SET status = 'cancelled', updated_at = ?
WHERE company_id = ? AND title LIKE '[Heartbeat]%'
WHERE company_id = ? AND is_heartbeat = 1
AND status IN ('done', 'in_review', 'todo', 'blocked', 'backlog')
AND id NOT IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY assignee_agent_id ORDER BY updated_at DESC, rowid DESC) as rn
FROM tasks
WHERE company_id = ? AND title LIKE '[Heartbeat]%' AND status != 'cancelled'
WHERE company_id = ? AND is_heartbeat = 1 AND status != 'cancelled'
) WHERE rn = 1
)`,
)
@@ -252,10 +255,11 @@ export function buildTaskStatusMethods(
t.assignee_agent_id as assigneeAgentId, t.workspace_id as workspaceId,
t.status, t.priority, t.active_run_id as activeRunId,
t.session_json as sessionJson, t.session_display_id as sessionDisplayId,
t.cost_usd as costUsd, t.created_at as createdAt, t.updated_at as updatedAt
t.cost_usd as costUsd, t.is_heartbeat as isHeartbeat,
t.created_at as createdAt, t.updated_at as updatedAt
from tasks t
inner join agents a on t.assignee_agent_id = a.id
where t.company_id = ? and t.status = 'in_review' and a.reports_to = ?
where t.company_id = ? and t.status = 'in_review' and a.reports_to = ? and t.is_heartbeat = 0
order by ${taskPriorityOrderSql("t.priority")} desc, t.created_at asc`,
companyId,
managerId,
+24 -6
View File
@@ -3,13 +3,15 @@ import {
app,
BrowserWindow,
dialog,
ipcMain,
Menu,
nativeImage,
shell,
Tray,
} from "electron";
import type { IpcMainInvokeEvent } from "electron";
import { IPC_CHANNELS } from "@shared/contracts";
import type { DesktopResult } from "@shared/contracts";
import { ok } from "./ipc-core-common";
export interface DesktopShellMenuDependencies {
iconPath: string;
@@ -20,6 +22,10 @@ export interface DesktopShellMenuDependencies {
getMainWindow: () => BrowserWindow | null;
ensureWindow: () => Promise<BrowserWindow | null>;
requestQuitWithConfirmation: () => Promise<void>;
registerHandle: (
channel: string,
handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>,
) => void;
}
export function createDesktopShellMenu(deps: DesktopShellMenuDependencies) {
@@ -32,6 +38,7 @@ export function createDesktopShellMenu(deps: DesktopShellMenuDependencies) {
getMainWindow,
ensureWindow,
requestQuitWithConfirmation,
registerHandle,
} = deps;
let appTray: Tray | null = null;
@@ -231,18 +238,29 @@ export function createDesktopShellMenu(deps: DesktopShellMenuDependencies) {
}
function registerWindowControlIpc() {
// Window control IPC for custom title bar (Windows/Linux frameless windows)
ipcMain.handle(IPC_CHANNELS.windowMinimize, () => { getMainWindow()?.minimize(); });
ipcMain.handle(IPC_CHANNELS.windowMaximize, () => {
// Window control IPC for custom title bar (Windows/Linux frameless windows).
// Routed through registerHandle so the trusted-sender check blocks
// embedded webContents from closing/minimizing the app.
registerHandle(IPC_CHANNELS.windowMinimize, () => {
getMainWindow()?.minimize();
return ok(null);
});
registerHandle(IPC_CHANNELS.windowMaximize, () => {
const mainWindow = getMainWindow();
if (mainWindow?.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow?.maximize();
}
return ok(null);
});
registerHandle(IPC_CHANNELS.windowClose, () => {
getMainWindow()?.close();
return ok(null);
});
registerHandle(IPC_CHANNELS.windowIsMaximized, () => {
return ok(getMainWindow()?.isMaximized() ?? false);
});
ipcMain.handle(IPC_CHANNELS.windowClose, () => { getMainWindow()?.close(); });
ipcMain.handle(IPC_CHANNELS.windowIsMaximized, () => getMainWindow()?.isMaximized() ?? false);
}
function destroy() {
+7 -2
View File
@@ -1,7 +1,11 @@
import { createDesktopShellWindow, type DesktopShellWindowDependencies } from "./desktop-shell-window";
import { createDesktopShellMenu } from "./desktop-shell-menu";
import { createDesktopShellMenu, type DesktopShellMenuDependencies } from "./desktop-shell-menu";
export function createDesktopShellService(deps: DesktopShellWindowDependencies) {
export type DesktopShellServiceDependencies = DesktopShellWindowDependencies & {
registerHandle: DesktopShellMenuDependencies["registerHandle"];
};
export function createDesktopShellService(deps: DesktopShellServiceDependencies) {
const window = createDesktopShellWindow(deps);
const menu = createDesktopShellMenu({
iconPath: deps.iconPath,
@@ -12,6 +16,7 @@ export function createDesktopShellService(deps: DesktopShellWindowDependencies)
getMainWindow: window.getMainWindow,
ensureWindow: window.ensureWindow,
requestQuitWithConfirmation: window.requestQuitWithConfirmation,
registerHandle: deps.registerHandle,
});
function initialize() {
+5 -1
View File
@@ -84,6 +84,10 @@ export async function notifyHireApproved(
entityId: input.agentId,
detail: message,
});
} catch { /* activity logging itself failed — nothing more we can do */ }
} catch (activityError) {
log.error(
`[hire-hook] Activity logging also failed for agent ${input.agentId}: ${activityError instanceof Error ? activityError.message : String(activityError)}`,
);
}
}
}
+5
View File
@@ -34,6 +34,11 @@ export interface RegisterCoreHandlersDependencies {
getApiPort: () => number;
listConnectorModels: (connectorId: string) => Promise<Array<{ id: string; label: string }>>;
notify: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string }; batchKey?: string }) => void;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
}
export function ok<T>(data: T): DesktopResult<T> {
-51
View File
@@ -4,18 +4,13 @@ import {
approvalInputSchema,
cancelRunSchema,
commentInputSchema,
getAgentPermissionsSchema,
getConfigRevisionSchema,
heartbeatPolicySchema,
heartbeatSettingsSchema,
listCommentsSchema,
listConfigRevisionsSchema,
listRunEventsSchema,
rollbackConfigRevisionSchema,
runtimeStateSchema,
runLogChunkSchema,
runLogSchema,
setAgentPermissionSchema,
setHeartbeatPolicySchema,
setRuntimeStateSchema,
startRunSchema,
@@ -257,35 +252,6 @@ export function registerCoreOperationHandlers({
return ok(runId);
});
registerHandle(IPC_CHANNELS.listConfigRevisions, async (_event, payload) => {
const parsed = listConfigRevisionsSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError;
if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) {
return fail("AGENT_NOT_FOUND", "Agent not found in the current company.");
}
return ok(db.listConfigRevisions(parsed.agentId, parsed.companyId));
});
registerHandle(IPC_CHANNELS.getConfigRevision, async (_event, payload) => {
const parsed = getConfigRevisionSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError;
return ok(db.getConfigRevision(parsed.revisionId, parsed.companyId));
});
registerHandle(IPC_CHANNELS.rollbackConfigRevision, async (_event, payload) => {
const parsed = rollbackConfigRevisionSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError;
if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) {
return fail("AGENT_NOT_FOUND", "Agent not found in the current company.");
}
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);
const companyError = ensureCompanyExists(parsed.companyId);
@@ -296,23 +262,6 @@ export function registerCoreOperationHandlers({
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);
const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError;
if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) {
return fail("AGENT_NOT_FOUND", "Agent not found in the current company.");
}
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);
const companyError = ensureCompanyExists(parsed.companyId);
+17 -5
View File
@@ -6,7 +6,6 @@ import {
taskInputSchema,
} from "@shared/contracts";
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
import { isHeartbeatTaskTitle } from "./orchestration/heartbeat-task";
import { validateTaskCreation, checkCapabilityMatch } from "./task-quality-gates";
export function registerCoreTaskGraphHandlers({
@@ -19,6 +18,7 @@ export function registerCoreTaskGraphHandlers({
wakeAgentIfPossible,
handleTaskCreated,
handleTaskStatusChange,
logger,
}: Pick<
RegisterCoreHandlersDependencies,
| "registerHandle"
@@ -30,6 +30,7 @@ export function registerCoreTaskGraphHandlers({
| "wakeAgentIfPossible"
| "handleTaskCreated"
| "handleTaskStatusChange"
| "logger"
>) {
registerHandle(IPC_CHANNELS.saveGoal, async (_event, payload) => {
const parsed = goalInputSchema.parse(payload);
@@ -101,8 +102,9 @@ export function registerCoreTaskGraphHandlers({
}
const isNew = !parsed.id;
if (isNew) {
const isHeartbeat = isHeartbeatTaskTitle(parsed.title);
const validation = validateTaskCreation(parsed, isHeartbeat);
// User-created tasks are never heartbeat tasks; the is_heartbeat flag
// is set only by acquireHeartbeatTask in the orchestrator.
const validation = validateTaskCreation(parsed, false);
if (!validation.valid) {
return fail("TASK_VALIDATION_FAILED", validation.errors.join("; "));
}
@@ -143,7 +145,13 @@ export function registerCoreTaskGraphHandlers({
}
// User (board) manually advancing a flagged task to done: clear the review flag
if (parsed.status === "done") {
try { db.setTaskRequiresUserReview(id, false); } catch { /* best effort */ }
try {
db.setTaskRequiresUserReview(id, false);
} catch (error) {
logger.warn(
`[ipc-task] setTaskRequiresUserReview(false) failed for task ${id}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// Wake new assignee when task is reassigned (matches api-server.ts behavior)
if (parsed.assigneeAgentId && parsed.assigneeAgentId !== previousAssigneeAgentId) {
@@ -157,7 +165,11 @@ export function registerCoreTaskGraphHandlers({
if (!match.matched) {
capabilityWarning = match.warning;
}
} catch { /* capability check is advisory — never block task creation */ }
} catch (error) {
logger.warn(
`[ipc-task] capability check failed for new task ${id} (advisory, not blocking): ${error instanceof Error ? error.message : String(error)}`,
);
}
}
emitEvent({ type: "task-updated", taskId: id, action: isNew ? "created" : "updated" });
return ok({ id, capabilityWarning });
+2
View File
@@ -15,6 +15,7 @@ export function registerCoreWorkGraphHandlers(
| "handleApprovalResolved"
| "handleTaskCreated"
| "handleTaskStatusChange"
| "logger"
>,
) {
registerCoreAgentGraphHandlers({
@@ -35,5 +36,6 @@ export function registerCoreWorkGraphHandlers(
wakeAgentIfPossible: deps.wakeAgentIfPossible,
handleTaskCreated: deps.handleTaskCreated,
handleTaskStatusChange: deps.handleTaskStatusChange,
logger: deps.logger,
});
}
+19 -1
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import { IPC_CHANNELS } from "@shared/contracts";
import { ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
const recoveryRetrySchema = z.object({
taskId: z.string(),
@@ -10,22 +10,30 @@ const recoveryRetrySchema = z.object({
const recoveryTaskSchema = z.object({
taskId: z.string(),
companyId: z.string(),
});
export function registerRecoveryHandlers({
registerHandle,
db,
ensureCompanyExists,
publishDomainChanged,
wakeAgentIfPossible,
}: Pick<
RegisterDomainHandlersDependencies,
| "registerHandle"
| "db"
| "ensureCompanyExists"
| "publishDomainChanged"
| "wakeAgentIfPossible"
>) {
registerHandle(IPC_CHANNELS.recoveryRetry, async (_event, payload) => {
const parsed = recoveryRetrySchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError;
if (!db.belongsToCompany("tasks", parsed.taskId, parsed.companyId)) {
return fail("TASK_NOT_FOUND", "Task not found in the current company.");
}
db.retryInterruptedTask(parsed.taskId, parsed.agentId, parsed.companyId);
try {
wakeAgentIfPossible(parsed.agentId, parsed.companyId, "retry_interrupted");
@@ -38,6 +46,11 @@ export function registerRecoveryHandlers({
registerHandle(IPC_CHANNELS.recoveryDismiss, async (_event, payload) => {
const parsed = recoveryTaskSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError;
if (!db.belongsToCompany("tasks", parsed.taskId, parsed.companyId)) {
return fail("TASK_NOT_FOUND", "Task not found in the current company.");
}
db.dismissInterruptedTask(parsed.taskId);
publishDomainChanged();
return ok(null);
@@ -45,6 +58,11 @@ export function registerRecoveryHandlers({
registerHandle(IPC_CHANNELS.recoveryFail, async (_event, payload) => {
const parsed = recoveryTaskSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError;
if (!db.belongsToCompany("tasks", parsed.taskId, parsed.companyId)) {
return fail("TASK_NOT_FOUND", "Task not found in the current company.");
}
db.markInterruptedTaskFailed(parsed.taskId);
publishDomainChanged();
return ok(null);
+1
View File
@@ -162,6 +162,7 @@ export function createMainRuntimeSupport(state: MainRuntimeState) {
const policy = getConnectorModelPolicy(requireDb(), connectorId as ConnectorId);
return policy.enabledModels;
},
logger: log,
});
registerDomainHandlers({
+2 -1
View File
@@ -1,3 +1,4 @@
import { app } from "electron";
import { AgentApiServer, createApiServer } from "./api-server";
import { createAgentToken, initializeAgentTokenSigningKey } from "./agent-auth";
import { bootstrapApplication } from "./bootstrap-application";
@@ -53,7 +54,7 @@ export function createMainRuntime({
isDev,
devServerOrigin,
profileDir,
appDatabaseFactory: (targetProfileDir) => new AppDatabase(targetProfileDir),
appDatabaseFactory: (targetProfileDir) => new AppDatabase(targetProfileDir, { isPackaged: app.isPackaged }),
initSigningKey: initializeAgentTokenSigningKey,
windowStatePath,
preloadPath,
+12 -45
View File
@@ -193,29 +193,17 @@ function resolveProjectWakeTargets(
input: NormalizedAgentMessageInput,
): string[] {
if (!input.channelTargetId) return [];
const projectRows = db.queryAll<{ leadAgentId: string | null }>(
"select lead_agent_id as leadAgentId from projects where id = ? and company_id = ? limit 1",
input.channelTargetId, input.companyId,
);
const project = projectRows[0] ?? null;
const project = db.getProjectForUpdate(input.channelTargetId, input.companyId);
const participantIds = new Set<string>();
if (project?.leadAgentId) participantIds.add(project.leadAgentId);
const taskAssignees = db.queryAll<{ assigneeAgentId: string | null }>(
"select assignee_agent_id as assigneeAgentId from tasks where project_id = ? and company_id = ? and assignee_agent_id is not null",
input.channelTargetId, input.companyId,
);
for (const t of taskAssignees) {
if (t.assigneeAgentId) participantIds.add(t.assigneeAgentId);
for (const assigneeId of db.listProjectTaskAssigneeIds(input.channelTargetId, input.companyId)) {
participantIds.add(assigneeId);
}
if (participantIds.size === 0) return [];
const ids = Array.from(participantIds).filter((id) => id !== input.fromAgentId);
if (ids.length === 0) return [];
const activeAgents = db.queryAll<{ id: string }>(
`select id from agents where id in (${ids.map(() => "?").join(",")}) and company_id = ? and status != 'terminated'`,
...ids, input.companyId,
);
return activeAgents.map((a) => a.id);
return db.listActiveAgentIdsFromIds(input.companyId, ids);
}
export function resolveMessageWakeTargets(
@@ -229,22 +217,14 @@ export function resolveMessageWakeTargets(
}
if (input.channel === "department" && input.channelTargetId) {
const deptAgents = db.queryAll<{ id: string }>(
"select id from agents where company_id = ? and department = ? and status != 'terminated'",
input.companyId, input.channelTargetId,
);
for (const agent of deptAgents) {
if (agent.id !== input.fromAgentId) wakeTargets.add(agent.id);
for (const agentId of db.listDepartmentAgentIds(input.companyId, input.channelTargetId)) {
if (agentId !== input.fromAgentId) wakeTargets.add(agentId);
}
}
if (input.channel === "company") {
const topLevel = db.queryAll<{ id: string }>(
"select id from agents where company_id = ? and reports_to is null and status != 'terminated'",
input.companyId,
);
for (const agent of topLevel) {
if (agent.id !== input.fromAgentId) wakeTargets.add(agent.id);
for (const agentId of db.listTopLevelAgentIds(input.companyId)) {
if (agentId !== input.fromAgentId) wakeTargets.add(agentId);
}
}
@@ -255,12 +235,8 @@ export function resolveMessageWakeTargets(
}
if (input.channel === "incident") {
const activeAgents = db.queryAll<{ id: string }>(
"select id from agents where company_id = ? and status not in ('terminated', 'pending_approval')",
input.companyId,
);
for (const agent of activeAgents) {
if (agent.id !== input.fromAgentId) wakeTargets.add(agent.id);
for (const agentId of db.listIncidentPingableAgentIds(input.companyId)) {
if (agentId !== input.fromAgentId) wakeTargets.add(agentId);
}
}
@@ -303,11 +279,7 @@ export function dispatchAgentMessage(
!normalized.fromHumanUser &&
(message.channel !== "direct" || message.toAgentId === HUMAN_SENDER_ID)
) {
const senderRows = deps.db.queryAll<{ name: string }>(
"select name from agents where id = ? limit 1",
message.fromAgentId,
);
const senderName = senderRows[0]?.name ?? "Agent";
const senderName = deps.db.findAgentName(message.fromAgentId) ?? "Agent";
deps.notify({
title: truncateForNotification(`${senderName} · ${message.subject}`, 80),
body: truncateForNotification(message.body, 140),
@@ -327,12 +299,7 @@ export function dispatchAgentMessage(
const mentionResults: MentionResult[] = [];
if (deps.wakeAgentIfPossible) {
const agentNameRows = wakeTargets.length > 0
? deps.db.queryAll<{ id: string; name: string }>(
`select id, name from agents where id in (${wakeTargets.map(() => "?").join(",")}) and company_id = ?`,
...wakeTargets, normalized.companyId,
)
: [];
const agentNameRows = deps.db.listAgentNamesByIds(normalized.companyId, wakeTargets);
const agentNameMap = new Map(agentNameRows.map((a) => [a.id, a.name]));
for (const wakeTarget of wakeTargets) {
const agentName = agentNameMap.get(wakeTarget) ?? wakeTarget;
+12 -2
View File
@@ -17,7 +17,11 @@ export interface NotificationOptions {
interface NotificationServiceDependencies {
getMainWindow: () => BrowserWindow | null;
emitEvent: (event: DesktopEvent) => void;
logger: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
/** Returns the active UI locale ("en", "zh", ...). Batch summary copy is
* localized; fallbacks to "en" when unset. */
getLocale?: () => string | null | undefined;
@@ -198,7 +202,13 @@ export function createNotificationService(deps: NotificationServiceDependencies)
// Dismiss any still-visible notifications so Windows toast host does not
// leave ghost toasts after quit.
for (const notification of activeNotifications) {
try { notification.close(); } catch { /* toast may have already closed */ }
try {
notification.close();
} catch (error) {
deps.logger.warn(
`[notification] notification.close() during destroy failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
activeNotifications.clear();
}
+3 -9
View File
@@ -2,14 +2,8 @@
* Heartbeat task title conventions.
*
* Heartbeat tasks are synthesized by the orchestrator when an agent wakes with
* no actionable work; they carry the `[Heartbeat]` title prefix so the rest of
* the system can detect them without a dedicated `kind` column.
*
* Centralizing the prefix here prevents drift between the writer (heartbeat
* dispatch) and readers (automation, escalation, route filters).
* no actionable work. The `tasks.is_heartbeat` column is the authoritative
* flag; the `[Heartbeat]` title prefix remains only for human-readable display
* so existing runs, logs, and UI affordances stay stable.
*/
export const HEARTBEAT_TASK_TITLE_PREFIX = "[Heartbeat]";
export function isHeartbeatTaskTitle(title: string | null | undefined): boolean {
return !!title && title.startsWith(HEARTBEAT_TASK_TITLE_PREFIX);
}
+5 -1
View File
@@ -212,7 +212,11 @@ export function createHeartbeat(
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 */ }
} catch (error) {
logger.warn(
`[heartbeat] updateRunActor failed for run ${run.id}: ${error instanceof Error ? error.message : String(error)}`,
);
}
db.addActivity({
companyId,
+17 -25
View File
@@ -8,7 +8,7 @@
import { buildTaskPrompt } from "../prompt-builder";
import type { AppDatabase } from "../database";
import type { RuntimeSkillEntry } from "../skill-types";
import type { ProjectedSkill } from "../skill-types";
import type { ConnectorId, ExecutionMode } from "@shared/types";
import type { WorkspaceLockMode, EnqueueRunPayload } from "./types";
import { normalizeMessagesForPrompt, normalizeCommentsForPrompt } from "../message-normalize";
@@ -33,7 +33,7 @@ export interface TurnContextDeps {
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void };
getApiPort: () => number;
createRunToken: (agentId: string, companyId: string, runId: string) => string;
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => RuntimeSkillEntry[];
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => ProjectedSkill[];
getSessionHandoff?: (taskId: string) => string | null;
evaluatePreRunSessionRotation?: (taskId: string, connectorId: string) => {
rotated: boolean;
@@ -109,7 +109,7 @@ function resolveSkills(
connectorId: string,
agentName: string,
deps: Pick<TurnContextDeps, "listRuntimeSkillEntries" | "logger">,
): RuntimeSkillEntry[] {
): ProjectedSkill[] {
try {
return deps.listRuntimeSkillEntries(companyId, agentId, connectorId);
} catch (err) {
@@ -162,13 +162,21 @@ function resolveSession(
return { taskSession, sessionHandoff: finalHandoff };
}
function extractThinkingEffort(metadataJson: string | null): string | null {
function extractThinkingEffort(
metadataJson: string | null,
logger: TurnContextDeps["logger"],
agentId: string,
): string | null {
try {
const meta = JSON.parse(metadataJson || "{}");
if (typeof meta.thinkingEffort === "string" && meta.thinkingEffort) {
return meta.thinkingEffort;
}
} catch { /* malformed metadata */ }
} catch (error) {
logger.warn(
`[run] malformed agent metadataJson for ${agentId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
return null;
}
@@ -251,13 +259,10 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
const assignedTasks = db.listAgentTasks(agent.id, companyId, ["todo", "in_progress", "blocked"]);
const reviewTasks = db.listReviewTasksForManager(agent.id, companyId);
const recentComments = assembleRecentComments(assignedTasks, reviewTasks, snapshot);
let sessionHandoff = resolveSessionHandoff(task.id, deps);
const initialHandoff = resolveSessionHandoff(task.id, deps);
const { taskSession, sessionHandoff } = resolveSession(db, task, connector.id, initialHandoff, deps);
const skills = resolveSkills(companyId, agent.id, connector.id, agent.name, deps);
const skillNames = skills
.map((s) => s.name ?? s.runtimeName)
.filter(Boolean);
const systemPrompt = apiUrl ? buildTaskPrompt({
agent,
apiUrl,
@@ -293,14 +298,7 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
recentDocuments: filterRecentDocuments(snapshot.documents, companyId, agent.id),
allConnectors: buildConnectorCatalog(db, snapshot.connectors),
sessionHandoff,
skillNames,
skillEntries: skills.map((s) => ({
name: s.name ?? s.runtimeName,
whenToUse: s.whenToUse,
allowedTools: s.allowedTools,
effort: s.effort,
contextMode: s.contextMode,
})),
skills,
companySkillCatalog: db.listCompanySkills(companyId).map((skill) => ({
name: skill.name,
slug: skill.slug,
@@ -312,12 +310,7 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
companyDocuments: snapshot.documents.filter((d) => d.companyId === companyId),
}) : null;
const { taskSession, sessionHandoff: finalHandoff } = resolveSession(db, task, connector.id, sessionHandoff, deps);
if (finalHandoff !== sessionHandoff && systemPrompt && finalHandoff) {
sessionHandoff = finalHandoff;
}
const thinkingEffort = extractThinkingEffort(agent.metadataJson);
const thinkingEffort = extractThinkingEffort(agent.metadataJson, deps.logger, agent.id);
const modelSelection = validateConnectorModelSelection(db, connector.id, agent.model);
if (!modelSelection.ok) {
throw new Error(modelSelection.error);
@@ -342,7 +335,6 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
sessionParams: taskSession.sessionParams,
systemPrompt,
trigger,
managedSkills: skills.map((s) => ({ runtimeName: s.runtimeName, sourcePath: s.sourcePath })),
taskSessionId: taskSession.id ?? runId,
executionMode: db.getCompanyExecutionMode(companyId) as "ai_ceo" | "ai_cofounder",
};
+2 -3
View File
@@ -2,7 +2,7 @@ import type { AppDatabase } from "../database";
import type { DesktopEvent } from "@shared/types";
import type { NotificationOptions } from "../notification-service";
import type { createBudgetEnforcementService } from "../budget-enforcement-service";
import type { RuntimeSkillEntry } from "../skill-types";
import type { ProjectedSkill } from "../skill-types";
export type WorkspaceLockMode = "exclusive" | "shared" | "none";
@@ -43,7 +43,6 @@ export interface EnqueueRunPayload {
sessionParams: Record<string, unknown> | null;
systemPrompt: string | null;
trigger: string | null;
managedSkills: Array<{ runtimeName: string; sourcePath: string }>;
/** Task session id used by SqliteSessionStore to load/append messages. */
taskSessionId: string;
/** Execution mode from the owning company. */
@@ -67,7 +66,7 @@ export interface OrchestrationDependencies {
hasWorkerReady: () => boolean;
enqueueWorkerRun: (payload: EnqueueRunPayload) => void;
isQuitting: () => boolean;
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => RuntimeSkillEntry[];
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => ProjectedSkill[];
budgetService: ReturnType<typeof createBudgetEnforcementService>;
/** Checkpoint engine for unified co-founder mode pause decisions. */
checkpointEngine?: { shouldPauseHeartbeat: (companyId: string, agentId: string) => boolean };
+14 -2
View File
@@ -1,3 +1,4 @@
import log from "electron-log/main.js";
import type { AgentRecord, AgentMessageRecord, ApprovalRecord, DocumentRecord, KnowledgeEntryRecord, MeetingRecord, TaskRecord } from "@shared/types";
import { inferTaskType } from "../connector-matching";
import { resolveAttachmentContent } from "./attachment-resolver";
@@ -112,7 +113,11 @@ export function buildMessagesSection(ctx: MessagesSectionCtx): string | null {
if (Array.isArray(atts) && atts.length > 0) {
attachmentNote = ` [Attached: ${atts.map((a) => a.title ?? "document").join(", ")}]`;
}
} catch { /* malformed attachment JSON */ }
} catch (error) {
log.warn(
`[prompt-builder] malformed attachmentsJson on message ${m.id}: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (ctx.companyDocuments && ctx.companyDocuments.length > 0) {
attachmentContent = resolveAttachmentContent(m.attachmentsJson, ctx.companyDocuments, 4000);
}
@@ -142,7 +147,14 @@ export function buildMeetingsSection(ctx: MeetingsSectionCtx): string | null {
const meetingLines = ctx.upcomingMeetings.slice(0, 5).map((m) => {
const organizer = m.organizerAgentId ? (ctx.allAgents.find(a => a.id === m.organizerAgentId)?.name ?? "Unknown") : "TBD";
let participants: string[];
try { participants = JSON.parse(m.participantAgentIds); } catch { participants = []; }
try {
participants = JSON.parse(m.participantAgentIds);
} catch (error) {
log.warn(
`[prompt-builder] malformed participantAgentIds on meeting ${m.id}: ${error instanceof Error ? error.message : String(error)}`,
);
participants = [];
}
const participantNames = participants
.map(id => ctx.allAgents.find(a => a.id === id)?.name ?? id)
.join(", ");
+12 -4
View File
@@ -1,5 +1,6 @@
import type { AgentRecord, AgentMessageRecord, ApprovalRecord, DocumentRecord, ExecutionMode, GoalRecord, KnowledgeEntryRecord, MeetingRecord, ProjectRecord, SprintRecord, TaskRecord } from "@shared/types";
import type { AppLocale } from "@shared/locale";
import type { ProjectedSkill } from "../skill-types";
import { buildSystemPrompt } from "./system-prompt";
import { renderWakeReason } from "./wake-reasons";
import { getSystemMessages } from "./messages";
@@ -35,10 +36,8 @@ export interface PromptContext {
allConnectors?: Array<{ id: string; label: string; status: string; enabledModels?: string[] }>;
/** D-07: Handoff markdown from a rotated session, injected as context for the agent */
sessionHandoff?: string | null;
/** Names of skills injected into this agent's runtime environment */
skillNames?: string[];
/** Full skill entries with semantic metadata for capability manifest */
skillEntries?: Array<{ name: string; whenToUse: string; allowedTools: string[]; effort: string; contextMode: string }>;
/** Skills visible to this agent via pi's progressive-disclosure model. */
skills?: ProjectedSkill[];
/** All company documents for attachment content resolution in messages */
companyDocuments?: DocumentRecord[];
/** Company-wide skill catalog for capability-aware delegation. */
@@ -52,6 +51,15 @@ export function buildTaskPrompt(ctx: PromptContext): string {
const systemPrompt = buildSystemPrompt(ctx);
const parts: string[] = [systemPrompt];
// Volatile per-turn context lives AFTER the cacheable system prefix so the
// Anthropic prompt cache hits across heartbeats. Putting the wall-clock
// time inside the system prompt would invalidate the cache on every call.
const now = new Date();
const dateStr = now.toISOString().split("T")[0];
const timeStr = now.toISOString().split("T")[1]?.slice(0, 5) ?? "00:00";
const dayOfWeek = m.dayNames[now.getUTCDay()] ?? "";
parts.push(`\n${m.currentDateSection(dayOfWeek, dateStr, timeStr)}`);
if (ctx.wakeReason) {
const explanation = renderWakeReason(ctx.wakeReason, {
companyId: ctx.agent.companyId,
@@ -270,20 +270,6 @@ export function importantRulesSection(companyId: string, agentId: string, runId:
`;
}
export function skillManifestEntries(entries: Array<{ name: string; whenToUse: string; allowedTools: string[]; effort: string }>): string {
const lines = entries.map((s) => {
const parts = [`- ${s.name}`];
if (s.whenToUse) parts.push(` Use when: ${s.whenToUse}`);
if (s.allowedTools.length > 0) parts.push(` Allowed tools: ${s.allowedTools.join(", ")}`);
if (s.effort !== "medium") parts.push(` Effort: ${s.effort}`);
return parts.join("\n");
});
return `Your domain skills:\n${lines.join("\n")}\nThese skills are loaded into your environment — use them when relevant to your work.`;
}
export function skillManifestNames(names: string[]): string {
return `Your domain skills: ${names.join(", ")}. These skills are loaded into your environment and provide domain expertise — use them when relevant to your work.`;
}
export const wakeReasonHeading = "# Wake Reason";
@@ -265,20 +265,6 @@ export function importantRulesSection(companyId: string, agentId: string, runId:
`;
}
export function skillManifestEntries(entries: Array<{ name: string; whenToUse: string; allowedTools: string[]; effort: string }>): string {
const lines = entries.map((s) => {
const parts = [`- ${s.name}`];
if (s.whenToUse) parts.push(` 使用场景:${s.whenToUse}`);
if (s.allowedTools.length > 0) parts.push(` 允许工具:${s.allowedTools.join("、")}`);
if (s.effort !== "medium") parts.push(` 资源消耗:${s.effort}`);
return parts.join("\n");
});
return `你的领域 skill:\n${lines.join("\n")}\n这些 skill 已加载到你的运行环境 —— 相关时请使用它们。`;
}
export function skillManifestNames(names: string[]): string {
return `你的领域 skill:${names.join("、")}。这些 skill 已加载到你的运行环境,为你提供领域专长 —— 相关时请使用它们。`;
}
export const wakeReasonHeading = "# 唤醒原因";
+29 -15
View File
@@ -1,5 +1,6 @@
import { DEPARTMENT_LABELS } from "@shared/constants";
import type { DepartmentId } from "@shared/types";
import { formatSkillsForPrompt } from "@mariozechner/pi-coding-agent";
import {
buildTaskSection,
buildReviewSection,
@@ -18,9 +19,9 @@ import {
buildDepartmentSection,
} from "./operating-instructions";
import type { PromptContext } from "./index";
import type { ProjectedSkill } from "../skill-types";
import { roleMatchesLeadershipKeywords } from "../agent-role-helpers";
import { getSystemMessages } from "./messages";
import type { SystemMessages } from "./messages";
function isLeadershipRole(ctx: PromptContext): boolean {
// HR roles inherit leadership scope for manager-style prompts;
@@ -29,14 +30,28 @@ function isLeadershipRole(ctx: PromptContext): boolean {
return roleMatchesLeadershipKeywords(ctx.agent.role, ctx.agent.title ?? null);
}
function buildSkillManifest(
m: SystemMessages,
skillEntries?: PromptContext["skillEntries"],
skillNames?: string[],
): string {
if (skillEntries && skillEntries.length > 0) return m.skillManifestEntries(skillEntries);
if (skillNames && skillNames.length > 0) return m.skillManifestNames(skillNames);
return "";
/**
* Render the skill manifest block using pi-coding-agent's XML format.
*
* The full SKILL.md content is NOT injected — only name/description/filePath.
* The agent loads SKILL.md on demand via the `read` tool (progressive
* disclosure). Pi's `formatSkillsForPrompt` expects its full `Skill` shape, so
* we supply synthetic values for the two fields we don't track (`sourceInfo`,
* `disableModelInvocation`). Cast is safe because pi's XML renderer only reads
* `name`, `description`, `filePath`, and `disableModelInvocation`.
*/
function buildSkillManifest(skills: ProjectedSkill[] | undefined): string {
if (!skills || skills.length === 0) return "";
const piShapedSkills = skills.map((s) => ({
name: s.name,
description: s.description,
filePath: s.filePath,
baseDir: s.baseDir,
disableModelInvocation: false,
// sourceInfo is required by pi's Skill interface but never read by
// formatSkillsForPrompt. Leaving it undefined and asserting the shape.
}));
return formatSkillsForPrompt(piShapedSkills as never);
}
export function buildSystemPrompt(ctx: PromptContext): string {
@@ -46,11 +61,10 @@ export function buildSystemPrompt(ctx: PromptContext): string {
const isCEO = ctx.chainOfCommand.length === 0;
const isManager = !isCEO && (ctx.directReports.length > 0 || isLeadershipRole(ctx));
const now = new Date();
const dateStr = now.toISOString().split("T")[0];
const timeStr = now.toISOString().split("T")[1]?.slice(0, 5) ?? "00:00";
const dayOfWeek = m.dayNames[now.getUTCDay()] ?? "";
sections.push(m.currentDateSection(dayOfWeek, dateStr, timeStr));
// NOTE: the current date/time is intentionally NOT rendered here. This
// function's output is intended to be cacheable by the Anthropic prompt
// cache keyed by a stable prefix; per-turn volatile values live in the
// buildTaskPrompt() tail so the cached prefix hits across heartbeats.
const budgetPct = ctx.agent.budgetMonthlyUsd > 0
? Math.round((ctx.agent.spentMonthlyUsd / ctx.agent.budgetMonthlyUsd) * 100)
@@ -64,7 +78,7 @@ export function buildSystemPrompt(ctx: PromptContext): string {
budgetMonthly: ctx.agent.budgetMonthlyUsd,
spentMonthly: ctx.agent.spentMonthlyUsd,
budgetPctUsed: budgetPct,
skillManifest: buildSkillManifest(m, ctx.skillEntries, ctx.skillNames),
skillManifest: buildSkillManifest(ctx.skills),
}));
const capabilityRegistrySection = buildCapabilityRegistrySection({
+20 -6
View File
@@ -3,7 +3,11 @@ import type { ThreadIntegrationService } from "./thread-integration";
interface RunHousekeepingDeps {
db: AppDatabase;
logger: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
threadIntegration: ThreadIntegrationService;
}
@@ -34,6 +38,7 @@ function shouldProduceKnowledge(
db: AppDatabase,
taskId: string,
agentId: string,
logger: RunHousekeepingDeps["logger"],
): boolean {
try {
const taskTypeRow = db.queryAll<{ taskType: string | null }>(
@@ -43,13 +48,18 @@ function shouldProduceKnowledge(
if (taskTypeRow?.taskType && KNOWLEDGE_PRODUCING_TASK_TYPES.has(taskTypeRow.taskType)) {
return true;
}
} catch {
// Task deleted; fall through to agent check
} catch (error) {
logger.warn(
`[run-housekeeping] task lookup failed for ${taskId}; falling back to agent department check: ${error instanceof Error ? error.message : String(error)}`,
);
}
try {
const agent = db.getAgent(agentId);
return Boolean(agent.department && KNOWLEDGE_PRODUCING_DEPARTMENTS.has(agent.department));
} catch {
} catch (error) {
logger.warn(
`[run-housekeeping] agent lookup failed for ${agentId}: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
}
}
@@ -79,14 +89,18 @@ export function createRunHousekeeping({ db, logger, threadIntegration }: RunHous
status === "succeeded" &&
summary &&
summary.length > 50 &&
shouldProduceKnowledge(db, taskId, agentId)
shouldProduceKnowledge(db, taskId, agentId, logger)
) {
try {
let topic = `Run ${runId.slice(0, 8)}`;
try {
const task = db.getTask(taskId);
topic = task.title;
} catch { /* task may have been deleted */ }
} catch (error) {
logger.warn(
`[run-housekeeping] getTask failed for ${taskId}; using run id as topic: ${error instanceof Error ? error.message : String(error)}`,
);
}
db.saveKnowledgeEntry({
companyId,
+2 -2
View File
@@ -6,7 +6,7 @@
import type { AppDatabase } from "./database";
import { createSkillService } from "./skill-service";
import type { createSessionManagementService } from "./session-management-service";
import type { RuntimeSkillEntry } from "./skill-types";
import type { ProjectedSkill } from "./skill-types";
interface Logger {
info: (...args: unknown[]) => void;
@@ -16,7 +16,7 @@ interface Logger {
export function makeListRuntimeSkillEntries(deps: { db: AppDatabase; logger: Logger }) {
let cache: ReturnType<typeof createSkillService> | null = null;
return (companyId: string, agentId: string, connectorId: string): RuntimeSkillEntry[] => {
return (companyId: string, agentId: string, connectorId: string): ProjectedSkill[] => {
try {
if (!cache) cache = createSkillService({ db: deps.db, profileDir: deps.db.profileDir, logger: deps.logger });
return cache.listRuntimeSkillEntries(companyId, agentId, connectorId);
+1 -3
View File
@@ -51,8 +51,6 @@ export function createPiRuntimeServices(deps: PiRuntimeServicesDeps): PiRuntimeS
credentialResolver,
oauthLoginService,
runtimeBridge,
release: () => {
runtimeBridge.approvalHook.cancelAll("AgentCompany shutting down.");
},
release: () => {},
};
}
+131 -236
View File
@@ -1,23 +1,25 @@
import { app, nativeTheme } from "electron";
import { nativeTheme } from "electron";
import type { IpcMainInvokeEvent } from "electron";
import type { AgentApiServer } from "./api-server";
import type { AppDatabase } from "./database";
import { BrowserManager } from "./browser-manager";
import { createAutomationService, autoAssignSkillsToAgent } from "./automation-service";
import { createBrowserActionService } from "./browser-action-service";
import type { DesktopResult } from "@shared/contracts";
import { createConnectorHealthService } from "./connector-health-service";
import { createDesktopShellService } from "./desktop-shell-service";
import { createOnboardingService } from "./onboarding-service";
import { createOrchestrationService } from "./orchestration-service";
import { createSkillService } from "./skill-service";
import { createUpdaterService } from "./updater-service";
import { createWorkerService } from "./worker-service";
import { createAppLifecycleService } from "./app-lifecycle-service";
import { createBudgetEnforcementService } from "./budget-enforcement-service";
import { createSessionManagementService } from "./session-management-service";
import { createPiRuntimeServices, type PiRuntimeServices } from "./service-graph-pi-runtime";
import type { DesktopEvent, RunRecord } from "@shared/types";
import type { AutomationEventContext } from "./automation-service";
import type { DesktopEvent } from "@shared/types";
import type { NotificationOptions } from "./notification-service";
import type { createAutomationService } from "./automation-service";
import type { createBrowserActionService } from "./browser-action-service";
import type { createOnboardingService } from "./onboarding-service";
import type { createOrchestrationService } from "./orchestration-service";
import type { createUpdaterService } from "./updater-service";
import { createLeafServices } from "./service-graph/create-leaf-services";
import { createDesktopShell } from "./service-graph/create-desktop-shell";
import { buildWorkerService } from "./service-graph/create-worker-service";
import { buildOrchestrationService } from "./service-graph/create-orchestration-service";
import { createBackgroundServices } from "./service-graph/create-background-services";
import { createLifecycle } from "./service-graph/create-lifecycle";
import { LateBinding } from "./service-graph/late-binding";
interface ServiceGraphDependencies {
db: AppDatabase;
@@ -46,145 +48,93 @@ interface ServiceGraphDependencies {
onRunCompleted?: (runId: string, taskId: string, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void;
/** Checkpoint engine for unified co-founder mode pause decisions in scheduler. */
checkpointEngine?: { shouldPauseHeartbeat: (companyId: string, agentId: string) => boolean };
/** Trusted-sender IPC wrapper from createIpcSupport. Window-control channels use it. */
registerHandle: (
channel: string,
handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>,
) => void;
}
export function createServiceGraph({
db,
apiServerRef,
browserManagerRef,
isQuittingRef,
preloadPath,
rendererDist,
iconPath,
isDev,
devServerUrl,
windowStatePath,
workerPath,
logger,
emitEvent,
publishDomainChanged,
notify,
createRunToken,
seedDefaultAutomationRules,
onRunStarted,
onRunCompleted,
checkpointEngine,
}: ServiceGraphDependencies) {
/**
* Thin orchestrator: assembles services in a deterministic topological order
* and exposes them via read-only getters. All wiring is performed via
* explicit dependency passing; late-bound cycles are handled by
* `LateBinding` slots that throw if accessed before assignment.
*/
export function createServiceGraph(deps: ServiceGraphDependencies) {
const {
db,
apiServerRef,
browserManagerRef,
isQuittingRef,
preloadPath,
rendererDist,
iconPath,
isDev,
devServerUrl,
windowStatePath,
workerPath,
logger,
emitEvent,
publishDomainChanged,
notify,
createRunToken,
seedDefaultAutomationRules,
onRunStarted,
onRunCompleted,
checkpointEngine,
registerHandle,
} = deps;
// Service slots owned by the graph. Null before init() completes and after
// the lifecycle drain has torn services down.
let desktopShellService: ReturnType<typeof createDesktopShell> | null = null;
let connectorHealthService: ReturnType<typeof createConnectorHealthService> | null = null;
let workerService: ReturnType<typeof buildWorkerService> | null = null;
let orchestrationService: ReturnType<typeof createOrchestrationService> | null = null;
let automationService: ReturnType<typeof createAutomationService> | null = null;
let browserActionService: ReturnType<typeof createBrowserActionService> | null = null;
let connectorHealthService: ReturnType<typeof createConnectorHealthService> | null = null;
let onboardingService: ReturnType<typeof createOnboardingService> | null = null;
let desktopShellService: ReturnType<typeof createDesktopShellService> | null = null;
let orchestrationService: ReturnType<typeof createOrchestrationService> | null = null;
let workerService: ReturnType<typeof createWorkerService> | null = null;
let updaterService: ReturnType<typeof createUpdaterService> | null = null;
let appLifecycleService: ReturnType<typeof createAppLifecycleService> | null = null;
// Eagerly constructed inside init() and resolved via getOrCreate* getters.
let skillService: ReturnType<typeof createSkillService> | null = null;
let budgetEnforcementService: ReturnType<typeof createBudgetEnforcementService> | null = null;
let sessionManagementService: ReturnType<typeof createSessionManagementService> | null = null;
let appLifecycleService: ReturnType<typeof createLifecycle> | null = null;
let leaf: ReturnType<typeof createLeafServices> | null = null;
const piRuntimeRef: { current: PiRuntimeServices | null } = { current: null };
function getOrCreateSkillService() {
if (!skillService) throw new Error("Skill service is not initialized.");
return skillService;
// Late-bound slots resolve circular construction: worker needs orchestration
// (for dispatchRunToWorker), orchestration needs worker (for enqueueWorkerRun),
// orchestration needs automation (for dispatchAutomation), automation/orchestration
// need browser-action (for dispatchBrowserAction). Each is filled inside
// init() before the corresponding getter closure is ever invoked.
const orchestrationRef = new LateBinding<ReturnType<typeof createOrchestrationService>>("orchestrationService");
const automationRef = new LateBinding<ReturnType<typeof createAutomationService>>("automationService");
const browserActionRef = new LateBinding<ReturnType<typeof createBrowserActionService>>("browserActionService");
const updaterRef = new LateBinding<ReturnType<typeof createUpdaterService>>("updaterService");
function requireLeaf() {
if (!leaf) throw new Error("Leaf services are not initialized.");
return leaf;
}
function getOrCreateBudgetEnforcementService() {
if (!budgetEnforcementService) throw new Error("Budget enforcement service is not initialized.");
return budgetEnforcementService;
}
function getOrCreateSessionManagementService() {
if (!sessionManagementService) throw new Error("Session management service is not initialized.");
return sessionManagementService;
}
const wakeAgentIfPossible =(agentId: string | null | undefined, companyId: string, trigger: string) => {
if (!orchestrationService) {
throw new Error("Orchestration service is not initialized.");
}
return orchestrationService.wakeAgentIfPossible(agentId, companyId, trigger);
};
const dispatchBrowserAction = async (actionId: string, companyId: string) => {
if (!browserActionService) {
throw new Error("Browser action service is not initialized");
}
return browserActionService.dispatchBrowserAction(actionId, companyId);
};
const dispatchAutomation = (trigger: string, ctx: AutomationEventContext) => {
if (!automationService) {
throw new Error("Automation service is not initialized.");
}
automationService.dispatchAutomation(trigger, ctx);
};
const processRunCompletion = (run: RunRecord, event: Parameters<ReturnType<typeof createAutomationService>["processRunCompletion"]>[1]) => {
if (!automationService) {
throw new Error("Automation service is not initialized.");
}
automationService.processRunCompletion(run, event);
};
const queueTaskRun = (taskId: string, trigger: string, actor: string) => {
if (!orchestrationService) {
throw new Error("Orchestration service is not initialized.");
}
return orchestrationService.queueTaskRun(taskId, trigger, actor);
};
const dispatchRunToWorker = (params: Parameters<ReturnType<typeof createOrchestrationService>["dispatchRunToWorker"]>[0]) => {
if (!orchestrationService) {
throw new Error("Orchestration service is not initialized.");
}
return orchestrationService.dispatchRunToWorker(params);
};
function init() {
nativeTheme.themeSource = db.listSnapshot().theme;
// Eagerly construct leaf services that other services depend on. Keeps
// the graph deterministic and lets getOrCreate* getters stay pure readers.
skillService = createSkillService({ db, profileDir: db.profileDir, logger });
budgetEnforcementService = createBudgetEnforcementService({ db, logger, notify, publishDomainChanged });
sessionManagementService = createSessionManagementService({ db, logger });
desktopShellService = createDesktopShellService({
leaf = createLeafServices({ db, logger, notify, publishDomainChanged });
desktopShellService = createDesktopShell({
db,
preloadPath,
rendererDist,
iconPath,
isDev,
devServerUrl,
windowStatePath,
shouldQuit: () => isQuittingRef.current,
onRequestQuit: () => {
isQuittingRef.current = true;
},
onCheckForUpdates: async () => {
if (!updaterService) throw new Error("Updater service is not initialized.");
await updaterService.checkForUpdates();
},
onBackupProfile: async () => {
const target = await import("electron").then(({ dialog }) => dialog.showOpenDialog({
properties: ["openDirectory", "createDirectory"],
}));
if (target.canceled || target.filePaths.length === 0) return;
await db.exportBackup(`${target.filePaths[0]!}/agentcompany-backup-${Date.now()}`);
},
onShortcut: (action) => emitEvent({ type: "shortcut", action }),
isQuittingRef,
emitEvent,
notify,
getActiveRunCount: () => {
try {
const snapshot = db.listSnapshot();
return snapshot.runs.filter((r) => r.status === "running" || r.status === "queued").length;
} catch (error) {
logger.warn("[service-graph] getActiveRunCount failed:", error);
return 0;
}
},
registerHandle,
updaterRef,
logger,
});
desktopShellService.initialize();
piRuntimeRef.current = createPiRuntimeServices({
db,
@@ -204,25 +154,24 @@ export function createServiceGraph({
credentialStore: piRuntimeRef.current.credentialStore,
});
workerService = createWorkerService({
workerService = buildWorkerService({
db,
logger,
workerPath,
isQuitting: () => isQuittingRef.current,
isQuittingRef,
publishDomainChanged,
emitEvent,
wakeAgentIfPossible,
dispatchRunToWorker,
processRunCompletion: (run, event) => processRunCompletion(run, event),
orchestrationRef,
automationRef,
onRunStarted,
onRunCompleted,
notify,
budgetService: getOrCreateBudgetEnforcementService(),
sessionService: getOrCreateSessionManagementService(),
getRuntimeBridge: () => piRuntimeRef.current?.runtimeBridge ?? null,
budgetService: leaf.budgetEnforcementService,
sessionService: leaf.sessionManagementService,
piRuntimeRef,
});
orchestrationService = createOrchestrationService({
orchestrationService = buildOrchestrationService({
db,
logger,
publishDomainChanged,
@@ -230,104 +179,52 @@ export function createServiceGraph({
notify,
createRunToken,
getApiPort: () => apiServerRef.current?.port ?? 0,
dispatchAutomation,
hasWorkerReady: () => Boolean(workerService?.getWorker()),
enqueueWorkerRun: (payload) => {
workerService?.getWorker()?.postMessage({ type: "enqueue-run", payload });
},
isQuitting: () => isQuittingRef.current,
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => {
try {
return getOrCreateSkillService().listRuntimeSkillEntries(companyId, agentId, connectorId);
} catch (error) {
logger.warn(`[service-graph] listRuntimeSkillEntries failed for agent ${agentId}:`, error);
return [];
}
},
budgetService: getOrCreateBudgetEnforcementService(),
isQuittingRef,
skillService: leaf.skillService,
budgetService: leaf.budgetEnforcementService,
sessionService: leaf.sessionManagementService,
workerService,
automationRef,
browserActionRef,
checkpointEngine,
getSessionHandoff: (taskId: string) => {
try {
return getOrCreateSessionManagementService().getHandoffContext(taskId);
} catch (error) {
logger.warn(`[service-graph] getSessionHandoff failed for task ${taskId}:`, error);
return null;
}
},
evaluatePreRunSessionRotation: (taskId: string, connectorId: string) => {
try {
const sessionSvc = getOrCreateSessionManagementService();
const activeSession = db.getActiveTaskSession(taskId);
if (!activeSession) return { rotated: false, handoff: null };
const result = sessionSvc.evaluateRotation(activeSession, connectorId);
if (result.shouldRotate) {
sessionSvc.rotateSession(activeSession);
const handoff = sessionSvc.getHandoffContext(taskId);
return { rotated: true, handoff };
}
return { rotated: false, handoff: null };
} catch (error) {
logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for task ${taskId}:`, error);
return { rotated: false, handoff: null };
}
},
});
orchestrationRef.set(orchestrationService);
workerService.createWorker();
automationService = createAutomationService({
const background = createBackgroundServices({
db,
logger,
emitEvent,
publishDomainChanged,
notify,
wakeAgentIfPossible,
dispatchBrowserAction,
isQuitting: () => isQuittingRef.current,
});
onboardingService = createOnboardingService({
db,
seedDefaultAutomationRules,
queueTaskRun,
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);
},
});
browserActionService = createBrowserActionService({
db,
isQuittingRef,
browserManagerRef,
publishDomainChanged,
skillService: leaf.skillService,
orchestrationService,
seedDefaultAutomationRules,
});
browserActionService.resetBrowserManager();
automationService = background.automationService;
browserActionService = background.browserActionService;
onboardingService = background.onboardingService;
updaterService = background.updaterService;
automationRef.set(automationService);
browserActionRef.set(browserActionService);
updaterRef.set(updaterService);
updaterService = createUpdaterService({
appLifecycleService = createLifecycle({
db,
logger,
notify,
});
updaterService.attachListeners();
appLifecycleService = createAppLifecycleService({
getMainWindow: () => desktopShellService?.getMainWindow() ?? null,
ensureWindow: async () => desktopShellService?.ensureWindow() ?? null,
shouldQuit: () => isQuittingRef.current,
onBeforeQuit: () => {
isQuittingRef.current = true;
orchestrationService?.stopAllSchedulers();
automationService?.destroy();
apiServerRef.current?.stop();
apiServerRef.current = null;
browserManagerRef.current?.shutdown();
browserManagerRef.current = null;
desktopShellService?.destroy();
isQuittingRef,
apiServerRef,
browserManagerRef,
piRuntimeRef,
orchestrationService,
automationService,
workerService,
desktopShellService,
clearServiceRefs: () => {
desktopShellService = null;
workerService?.destroy();
workerService = null;
browserActionService = null;
automationService = null;
@@ -336,16 +233,14 @@ export function createServiceGraph({
orchestrationService = null;
updaterService = null;
appLifecycleService = null;
skillService = null;
budgetEnforcementService = null;
sessionManagementService = null;
piRuntimeRef.current?.release();
piRuntimeRef.current = null;
db.close();
// Force exit after grace period — covers all platforms (macOS Dock, Windows tray, Linux)
// Worker child processes (claude/codex CLI) may keep the event loop alive indefinitely
setTimeout(() => { app.exit(0); }, 3000);
leaf = null;
},
lateBindings: [
orchestrationRef as LateBinding<unknown>,
automationRef as LateBinding<unknown>,
browserActionRef as LateBinding<unknown>,
updaterRef as LateBinding<unknown>,
],
});
}
@@ -360,9 +255,9 @@ export function createServiceGraph({
getOnboardingService: () => onboardingService,
getUpdaterService: () => updaterService,
getAppLifecycleService: () => appLifecycleService,
getOrCreateSkillService,
getOrCreateBudgetEnforcementService,
getOrCreateSessionManagementService,
getOrCreateSkillService: () => requireLeaf().skillService,
getOrCreateBudgetEnforcementService: () => requireLeaf().budgetEnforcementService,
getOrCreateSessionManagementService: () => requireLeaf().sessionManagementService,
getPiRuntime: () => piRuntimeRef.current,
};
}
@@ -0,0 +1,95 @@
import type { AppDatabase } from "../database";
import { BrowserManager } from "../browser-manager";
import type { DesktopEvent } from "@shared/types";
import { createAutomationService } from "../automation-service";
import { createBrowserActionService } from "../browser-action-service";
import { createOnboardingService } from "../onboarding-service";
import { createUpdaterService } from "../updater-service";
import type { createOrchestrationService } from "../orchestration-service";
import type { createSkillService } from "../skill-service";
import type { NotificationOptions } from "../notification-service";
export interface BackgroundServicesDeps {
db: AppDatabase;
logger: {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
emitEvent: (event: DesktopEvent) => void;
publishDomainChanged: () => void;
notify: (options: NotificationOptions) => void;
isQuittingRef: { current: boolean };
browserManagerRef: { current: BrowserManager | null };
skillService: ReturnType<typeof createSkillService>;
orchestrationService: ReturnType<typeof createOrchestrationService>;
seedDefaultAutomationRules: (companyId: string) => void;
}
export interface BackgroundServices {
automationService: ReturnType<typeof createAutomationService>;
browserActionService: ReturnType<typeof createBrowserActionService>;
onboardingService: ReturnType<typeof createOnboardingService>;
updaterService: ReturnType<typeof createUpdaterService>;
}
/**
* Build background services that run off the main dispatch loop: automation
* triggers, browser-action dispatch, onboarding hire flow, and the updater.
* Orchestration is fully assembled at this point, so all dependencies are
* concrete references rather than lazy slots.
*/
export function createBackgroundServices({
db,
logger,
emitEvent,
publishDomainChanged,
notify,
isQuittingRef,
browserManagerRef,
skillService,
orchestrationService,
seedDefaultAutomationRules,
}: BackgroundServicesDeps): BackgroundServices {
const browserActionService = createBrowserActionService({
db,
browserManagerRef,
publishDomainChanged,
});
browserActionService.resetBrowserManager();
const dispatchBrowserAction = async (actionId: string, companyId: string) =>
browserActionService.dispatchBrowserAction(actionId, companyId);
const automationService = createAutomationService({
db,
logger,
emitEvent,
publishDomainChanged,
notify,
wakeAgentIfPossible: (agentId, companyId, trigger) =>
orchestrationService.wakeAgentIfPossible(agentId, companyId, trigger),
dispatchBrowserAction,
isQuitting: () => isQuittingRef.current,
});
const onboardingService = createOnboardingService({
db,
seedDefaultAutomationRules,
queueTaskRun: (taskId, trigger, actor) =>
orchestrationService.queueTaskRun(taskId, trigger, actor),
publishDomainChanged,
scanAndAssignSkills: async (companyId: string, _agentId: string) => {
// All company skills are visible to every agent via progressive disclosure.
// Seed the builtin catalog + any workspace-local SKILL.md bundles.
await skillService.seedBuiltinSkills(companyId);
await skillService.scanWorkspaceSkills(companyId);
},
});
const updaterService = createUpdaterService({ db, logger, notify });
updaterService.attachListeners();
return { automationService, browserActionService, onboardingService, updaterService };
}
@@ -0,0 +1,85 @@
import type { IpcMainInvokeEvent } from "electron";
import type { AppDatabase } from "../database";
import type { DesktopEvent } from "@shared/types";
import type { DesktopResult } from "@shared/contracts";
import { createDesktopShellService } from "../desktop-shell-service";
import type { NotificationOptions } from "../notification-service";
import type { LateBinding } from "./late-binding";
import type { createUpdaterService } from "../updater-service";
export interface DesktopShellDeps {
db: AppDatabase;
preloadPath: string;
rendererDist: string;
iconPath: string;
isDev: boolean;
devServerUrl?: string;
windowStatePath: string;
isQuittingRef: { current: boolean };
emitEvent: (event: DesktopEvent) => void;
notify: (options: NotificationOptions) => void;
registerHandle: (
channel: string,
handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>,
) => void;
updaterRef: LateBinding<ReturnType<typeof createUpdaterService>>;
logger: { warn: (...args: unknown[]) => void };
}
/**
* Build the desktop shell service. `updaterRef` is late-bound because the
* updater is constructed after the shell; the menu's "Check for updates"
* handler resolves it at call time.
*/
export function createDesktopShell({
db,
preloadPath,
rendererDist,
iconPath,
isDev,
devServerUrl,
windowStatePath,
isQuittingRef,
emitEvent,
notify,
registerHandle,
updaterRef,
logger,
}: DesktopShellDeps) {
const desktopShellService = createDesktopShellService({
preloadPath,
rendererDist,
iconPath,
isDev,
devServerUrl,
windowStatePath,
registerHandle,
shouldQuit: () => isQuittingRef.current,
onRequestQuit: () => {
isQuittingRef.current = true;
},
onCheckForUpdates: async () => {
await updaterRef.get().checkForUpdates();
},
onBackupProfile: async () => {
const target = await import("electron").then(({ dialog }) => dialog.showOpenDialog({
properties: ["openDirectory", "createDirectory"],
}));
if (target.canceled || target.filePaths.length === 0) return;
await db.exportBackup(`${target.filePaths[0]!}/agentcompany-backup-${Date.now()}`);
},
onShortcut: (action) => emitEvent({ type: "shortcut", action }),
notify,
getActiveRunCount: () => {
try {
const snapshot = db.listSnapshot();
return snapshot.runs.filter((r) => r.status === "running" || r.status === "queued").length;
} catch (error) {
logger.warn("[service-graph] getActiveRunCount failed:", error);
return 0;
}
},
});
desktopShellService.initialize();
return desktopShellService;
}
@@ -0,0 +1,46 @@
import type { AppDatabase } from "../database";
import { createBudgetEnforcementService } from "../budget-enforcement-service";
import { createSessionManagementService } from "../session-management-service";
import { createSkillService } from "../skill-service";
import type { NotificationOptions } from "../notification-service";
export interface LeafServicesDeps {
db: AppDatabase;
logger: {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
notify: (options: NotificationOptions) => void;
publishDomainChanged: () => void;
}
export interface LeafServices {
skillService: ReturnType<typeof createSkillService>;
budgetEnforcementService: ReturnType<typeof createBudgetEnforcementService>;
sessionManagementService: ReturnType<typeof createSessionManagementService>;
}
/**
* Build leaf services that no other service depends on except through this
* bundle. Constructed first so downstream factories can receive concrete
* references without null-checks.
*/
export function createLeafServices({
db,
logger,
notify,
publishDomainChanged,
}: LeafServicesDeps): LeafServices {
const skillService = createSkillService({ db, profileDir: db.profileDir, logger });
const budgetEnforcementService = createBudgetEnforcementService({
db,
logger,
notify,
publishDomainChanged,
});
const sessionManagementService = createSessionManagementService({ db, logger });
return { skillService, budgetEnforcementService, sessionManagementService };
}
+145
View File
@@ -0,0 +1,145 @@
import { app } from "electron";
import type { AgentApiServer } from "../api-server";
import type { AppDatabase } from "../database";
import { BrowserManager } from "../browser-manager";
import { createAppLifecycleService } from "../app-lifecycle-service";
import type { createAutomationService } from "../automation-service";
import type { createDesktopShellService } from "../desktop-shell-service";
import type { createOrchestrationService } from "../orchestration-service";
import type { createWorkerService } from "../worker-service";
import type { PiRuntimeServices } from "../service-graph-pi-runtime";
import type { LateBinding } from "./late-binding";
/** Overall budget for awaited shutdown drain (worker exit + API close). */
const SHUTDOWN_BUDGET_MS = 5_000;
export interface LifecycleDeps {
db: AppDatabase;
logger: {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
isQuittingRef: { current: boolean };
apiServerRef: { current: AgentApiServer | null };
browserManagerRef: { current: BrowserManager | null };
piRuntimeRef: { current: PiRuntimeServices | null };
orchestrationService: ReturnType<typeof createOrchestrationService>;
automationService: ReturnType<typeof createAutomationService>;
workerService: ReturnType<typeof createWorkerService>;
desktopShellService: ReturnType<typeof createDesktopShellService>;
/**
* Clears graph-owned service refs (so getters return null) after the drain
* completes. Mirrors the original synchronous null-out at the tail of the
* old onBeforeQuit.
*/
clearServiceRefs: () => void;
/**
* Late bindings owned by the graph; cleared after drain so any stray
* post-quit access fails loudly rather than returning stale data.
*/
lateBindings: Array<LateBinding<unknown>>;
}
/**
* Build the app lifecycle service and wire a deterministic shutdown drain
* that awaits the utility-process worker's exit (and API server close)
* before calling app.exit(). On 5s timeout we log the stuck run ids and
* exit(1) so operators see an unclean shutdown rather than a silent
* force-kill.
*/
export function createLifecycle(deps: LifecycleDeps) {
const {
db,
logger,
isQuittingRef,
apiServerRef,
browserManagerRef,
piRuntimeRef,
orchestrationService,
automationService,
workerService,
desktopShellService,
clearServiceRefs,
lateBindings,
} = deps;
let drainPromise: Promise<number> | null = null;
function listActiveRunIds(): string[] {
try {
return db.listSnapshot().runs
.filter((r) => r.status === "running" || r.status === "queued")
.map((r) => r.id);
} catch {
return [];
}
}
async function drain(): Promise<number> {
// Stop accepting new scheduled work first; cancel in-flight runs in the
// worker via the utility-process shutdown postMessage inside destroy().
try { orchestrationService.stopAllSchedulers(); } catch (err) { logger.warn("[lifecycle] stopAllSchedulers failed:", err); }
try { automationService.destroy(); } catch (err) { logger.warn("[lifecycle] automationService.destroy failed:", err); }
try { apiServerRef.current?.stop(); } catch (err) { logger.warn("[lifecycle] apiServer.stop failed:", err); }
apiServerRef.current = null;
try { browserManagerRef.current?.shutdown(); } catch (err) { logger.warn("[lifecycle] browserManager.shutdown failed:", err); }
browserManagerRef.current = null;
// workerService.destroy() sends "shutdown" to the utility process and has
// its own 2s SIGKILL safety net. Awaiting the worker's exit event closes
// the remaining event-loop holder (the utilityProcess handle).
const workerExit = new Promise<void>((resolve) => {
const worker = workerService.getWorker();
if (!worker) { resolve(); return; }
worker.once("exit", () => resolve());
});
workerService.destroy();
const timeout = new Promise<"timeout">((resolve) => {
const handle = setTimeout(() => resolve("timeout"), SHUTDOWN_BUDGET_MS);
if (typeof handle.unref === "function") handle.unref();
});
const outcome = await Promise.race([
workerExit.then(() => "drained" as const),
timeout,
]);
try { desktopShellService.destroy(); } catch (err) { logger.warn("[lifecycle] desktopShell.destroy failed:", err); }
try { piRuntimeRef.current?.release(); } catch (err) { logger.warn("[lifecycle] piRuntime.release failed:", err); }
piRuntimeRef.current = null;
try { db.close(); } catch (err) { logger.warn("[lifecycle] db.close failed:", err); }
clearServiceRefs();
for (const binding of lateBindings) binding.clear();
if (outcome === "timeout") {
const stuck = listActiveRunIds();
logger.error(
`[lifecycle] Drain exceeded ${SHUTDOWN_BUDGET_MS}ms; stuck runs: ${stuck.length ? stuck.join(", ") : "<none>"}`,
);
return 1;
}
return 0;
}
const appLifecycleService = createAppLifecycleService({
getMainWindow: () => desktopShellService.getMainWindow(),
ensureWindow: async () => desktopShellService.ensureWindow(),
shouldQuit: () => isQuittingRef.current,
onBeforeQuit: () => {
if (drainPromise) return;
isQuittingRef.current = true;
drainPromise = drain().catch((err) => {
logger.error("[lifecycle] drain threw unexpectedly:", err);
return 1;
});
drainPromise.then((code) => {
app.exit(code);
});
},
});
return appLifecycleService;
}
@@ -0,0 +1,111 @@
import type { AppDatabase } from "../database";
import type { DesktopEvent } from "@shared/types";
import { createOrchestrationService } from "../orchestration-service";
import type { createBudgetEnforcementService } from "../budget-enforcement-service";
import type { createSessionManagementService } from "../session-management-service";
import type { createSkillService } from "../skill-service";
import type { createWorkerService } from "../worker-service";
import type { createAutomationService, AutomationEventContext } from "../automation-service";
import type { createBrowserActionService } from "../browser-action-service";
import type { NotificationOptions } from "../notification-service";
import type { LateBinding } from "./late-binding";
export interface OrchestrationServiceDeps {
db: AppDatabase;
logger: {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
publishDomainChanged: () => void;
emitEvent: (event: DesktopEvent) => void;
notify: (options: NotificationOptions) => void;
createRunToken: (agentId: string, companyId: string, runId: string) => string;
getApiPort: () => number;
isQuittingRef: { current: boolean };
skillService: ReturnType<typeof createSkillService>;
budgetService: ReturnType<typeof createBudgetEnforcementService>;
sessionService: ReturnType<typeof createSessionManagementService>;
workerService: ReturnType<typeof createWorkerService>;
automationRef: LateBinding<ReturnType<typeof createAutomationService>>;
browserActionRef: LateBinding<ReturnType<typeof createBrowserActionService>>;
checkpointEngine?: { shouldPauseHeartbeat: (companyId: string, agentId: string) => boolean };
}
/**
* Build the orchestration service with resolved dependencies. Late bindings
* for automation and browser-action are required because orchestration's
* dispatchAutomation closure is invoked long after assembly — the slots are
* filled in the same init() pass, before any scheduler tick.
*/
export function buildOrchestrationService({
db,
logger,
publishDomainChanged,
emitEvent,
notify,
createRunToken,
getApiPort,
isQuittingRef,
skillService,
budgetService,
sessionService,
workerService,
automationRef,
checkpointEngine,
}: OrchestrationServiceDeps) {
const dispatchAutomation = (trigger: string, ctx: AutomationEventContext) => {
automationRef.get().dispatchAutomation(trigger, ctx);
};
return createOrchestrationService({
db,
logger,
publishDomainChanged,
emitEvent,
notify,
createRunToken,
getApiPort,
dispatchAutomation,
hasWorkerReady: () => Boolean(workerService.getWorker()),
enqueueWorkerRun: (payload) => {
workerService.getWorker()?.postMessage({ type: "enqueue-run", payload });
},
isQuitting: () => isQuittingRef.current,
listRuntimeSkillEntries: (companyId, agentId, connectorId) => {
try {
return skillService.listRuntimeSkillEntries(companyId, agentId, connectorId);
} catch (error) {
logger.warn(`[service-graph] listRuntimeSkillEntries failed for agent ${agentId}:`, error);
return [];
}
},
budgetService,
checkpointEngine,
getSessionHandoff: (taskId) => {
try {
return sessionService.getHandoffContext(taskId);
} catch (error) {
logger.warn(`[service-graph] getSessionHandoff failed for task ${taskId}:`, error);
return null;
}
},
evaluatePreRunSessionRotation: (taskId, connectorId) => {
try {
const activeSession = db.getActiveTaskSession(taskId);
if (!activeSession) return { rotated: false, handoff: null };
const result = sessionService.evaluateRotation(activeSession, connectorId);
if (result.shouldRotate) {
sessionService.rotateSession(activeSession);
const handoff = sessionService.getHandoffContext(taskId);
return { rotated: true, handoff };
}
return { rotated: false, handoff: null };
} catch (error) {
logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for task ${taskId}:`, error);
return { rotated: false, handoff: null };
}
},
});
}
@@ -0,0 +1,87 @@
import type { AppDatabase } from "../database";
import type { DesktopEvent, RunRecord } from "@shared/types";
import { createWorkerService } from "../worker-service";
import type { createOrchestrationService } from "../orchestration-service";
import type { createAutomationService } from "../automation-service";
import type { createBudgetEnforcementService } from "../budget-enforcement-service";
import type { createSessionManagementService } from "../session-management-service";
import type { PiRuntimeServices } from "../service-graph-pi-runtime";
import type { NotificationOptions } from "../notification-service";
import type { LateBinding } from "./late-binding";
export interface WorkerServiceDeps {
db: AppDatabase;
logger: {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
workerPath: string;
isQuittingRef: { current: boolean };
publishDomainChanged: () => void;
emitEvent: (event: DesktopEvent) => void;
orchestrationRef: LateBinding<ReturnType<typeof createOrchestrationService>>;
automationRef: LateBinding<ReturnType<typeof createAutomationService>>;
onRunStarted?: (runId: string, taskId: string, agentId: string, companyId: string) => void;
onRunCompleted?: (runId: string, taskId: string, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void;
notify: (options: NotificationOptions) => void;
budgetService: ReturnType<typeof createBudgetEnforcementService>;
sessionService: ReturnType<typeof createSessionManagementService>;
piRuntimeRef: { current: PiRuntimeServices | null };
}
/**
* Build the worker service. Orchestration and automation are late-bound via
* LateBinding slots because they are constructed after the worker but close
* over the worker's dispatch functions.
*/
export function buildWorkerService(deps: WorkerServiceDeps) {
const {
db,
logger,
workerPath,
isQuittingRef,
publishDomainChanged,
emitEvent,
orchestrationRef,
automationRef,
onRunStarted,
onRunCompleted,
notify,
budgetService,
sessionService,
piRuntimeRef,
} = deps;
const wakeAgentIfPossible = (agentId: string | null | undefined, companyId: string, trigger: string) =>
orchestrationRef.get().wakeAgentIfPossible(agentId, companyId, trigger);
const dispatchRunToWorker: Parameters<typeof createWorkerService>[0]["dispatchRunToWorker"] = (params) =>
orchestrationRef.get().dispatchRunToWorker(params);
const processRunCompletion = (
run: RunRecord,
event: Parameters<ReturnType<typeof createAutomationService>["processRunCompletion"]>[1],
) => {
automationRef.get().processRunCompletion(run, event);
};
return createWorkerService({
db,
logger,
workerPath,
isQuitting: () => isQuittingRef.current,
publishDomainChanged,
emitEvent,
wakeAgentIfPossible,
dispatchRunToWorker,
processRunCompletion,
onRunStarted,
onRunCompleted,
notify,
budgetService,
sessionService,
getRuntimeBridge: () => piRuntimeRef.current?.runtimeBridge ?? null,
});
}
+33
View File
@@ -0,0 +1,33 @@
/**
* One-time settable slot for services that participate in a cycle during
* assembly. `set()` may be called exactly once; `get()` throws until the
* slot has been set. Eliminates the lazy-null `let` pattern that service-graph
* previously used to wire `workerService.dispatchRunToWorker` against
* `orchestrationService` before the latter exists.
*/
export class LateBinding<T> {
private value: T | null = null;
private assigned = false;
constructor(private readonly label: string) {}
set(value: T): void {
if (this.assigned) {
throw new Error(`LateBinding<${this.label}> is already assigned.`);
}
this.value = value;
this.assigned = true;
}
get(): T {
if (!this.assigned || this.value === null) {
throw new Error(`LateBinding<${this.label}> accessed before assignment.`);
}
return this.value;
}
clear(): void {
this.value = null;
this.assigned = false;
}
}
+1 -54
View File
@@ -1,5 +1,5 @@
import { extname } from "node:path";
import type { FileInventoryEntry, SkillContextMode, TrustLevel } from "./skill-types.js";
import type { FileInventoryEntry, TrustLevel } from "./skill-types.js";
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -220,56 +220,3 @@ export function deriveTrustLevel(inventory: FileInventoryEntry[]): TrustLevel {
return "markdown_only";
}
/**
* Estimate the token count for a text string.
* Uses a rough 1:4 character-to-token ratio.
*/
export function estimateTokens(text: string): number {
if (text.length === 0) return 0;
return Math.ceil(text.length / 4);
}
/**
* Runtime semantics extracted from a skill's YAML frontmatter.
* These fields control how the skill interacts with the execution runtime.
*/
export interface SkillRuntimeSemantics {
allowedTools: string[];
hooks: string[];
contextMode: SkillContextMode;
isAgent: boolean;
effort: "low" | "medium" | "high";
whenToUse: string;
}
function toStringArray(value: unknown): string[] {
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string");
if (typeof value === "string") return value.split(",").map((s) => s.trim()).filter(Boolean);
return [];
}
/**
* Extract runtime semantics from parsed frontmatter.
* Missing fields default to safe, passive values.
*/
export function extractRuntimeSemantics(
frontmatter: Record<string, unknown>,
): SkillRuntimeSemantics {
const contextRaw = typeof frontmatter.context === "string" ? frontmatter.context : "inline";
const contextMode: SkillContextMode = contextRaw === "fork" ? "fork" : "inline";
const effortRaw = typeof frontmatter.effort === "string" ? frontmatter.effort : "medium";
const validEfforts = new Set(["low", "medium", "high"]);
const effort: "low" | "medium" | "high" = validEfforts.has(effortRaw)
? (effortRaw as "low" | "medium" | "high")
: "medium";
return {
allowedTools: toStringArray(frontmatter["allowed-tools"] ?? frontmatter.allowedTools),
hooks: toStringArray(frontmatter.hooks),
contextMode,
isAgent: frontmatter.agent === true || frontmatter.agent === "true",
effort,
whenToUse: typeof frontmatter.when_to_use === "string" ? frontmatter.when_to_use : "",
};
}
+14 -62
View File
@@ -3,43 +3,22 @@
export type { SkillSourceType, TrustLevel, FileInventoryEntry, SkillRecord, SkillImportResult, SkillUpdateStatus } from "@shared/types";
/**
* Skill execution context mode — controls how the skill is invoked.
* - "inline": skill content is injected into the current prompt (default)
* - "fork": skill runs in an isolated context / sub-conversation
* Runtime projection of a company skill, matching pi-coding-agent's progressive-
* disclosure model: only name/description/filePath/baseDir are exposed to the
* prompt. The full SKILL.md is loaded on-demand by the agent via the `read` tool.
*
* Fields mirror pi's `Skill` interface (the full Skill has sourceInfo +
* disableModelInvocation, which we don't need to forward to the prompt layer).
*/
export type SkillContextMode = "inline" | "fork";
/**
* Runtime representation of a skill entry for adapter injection.
* Includes strong semantic fields inspired by Reference's skill loader:
* allowed-tools, hooks, context mode, agent flag, effort hint.
*/
export interface RuntimeSkillEntry {
runtimeName: string;
sourcePath: string;
export interface ProjectedSkill {
/** Skill name (Agent Skills spec: lowercase-kebab, matches parent dir). */
name: string;
tokenEstimate: number;
/** Tools this skill is allowed to use. Empty = unrestricted. */
allowedTools: string[];
/** Lifecycle hooks the skill registers (e.g., "on_task_start", "on_run_complete"). */
hooks: string[];
/** Context mode: "inline" (default) or "fork" (isolated sub-conversation). */
contextMode: SkillContextMode;
/** Whether this skill acts as an autonomous agent (true) or passive knowledge (false). */
isAgent: boolean;
/** Effort hint: "low", "medium", "high". Informs scheduling and token budget allocation. */
effort: "low" | "medium" | "high";
/** When this skill should be activated. Empty = always active when assigned. */
whenToUse: string;
}
/**
* Minimal skill reference passed to worker threads.
* Subset of RuntimeSkillEntry to minimize serialization overhead.
*/
export interface ManagedSkillRef {
runtimeName: string;
sourcePath: string;
/** Skill description shown in the prompt manifest. */
description: string;
/** Absolute path to the SKILL.md file. */
filePath: string;
/** Directory containing SKILL.md (used for relative-path resolution). */
baseDir: string;
}
/**
@@ -49,30 +28,3 @@ export interface SkillAssignment {
agentId: string;
skillId: string;
}
/**
* Default token budget used when a connector does not declare an override.
*/
const DEFAULT_TOKEN_BUDGET = 30000;
/**
* Per-connector token budgets for skill content injection.
* Values represent the maximum token count of skill content that can be
* included in a single prompt for a given connector.
*/
export const TOKEN_BUDGETS: Readonly<Record<string, number>> = {
// High-context Anthropic-style connectors run with a larger injection budget.
"claude-pro": 50000,
"anthropic": 50000,
"amazon-bedrock": 50000,
"gemini-cli": 50000,
"google": 50000,
"google-vertex": 50000,
};
/**
* Resolves the token budget for a given connector id.
*/
export function resolveTokenBudget(connectorId: string): number {
return TOKEN_BUDGETS[connectorId] ?? DEFAULT_TOKEN_BUDGET;
}
-2
View File
@@ -58,7 +58,5 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
listRuntimeSkillEntries: runtime.listRuntimeSkillEntries,
scanWorkspaceSkills: runtime.scanWorkspaceSkills,
seedBuiltinSkills: runtime.seedBuiltinSkills,
assignBasicSkillsToAgent: runtime.assignBasicSkillsToAgent,
assignRoleSkillsToAgent: runtime.assignRoleSkillsToAgent,
};
}
+37 -115
View File
@@ -1,29 +1,8 @@
import { existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { loadSkills } from "@mariozechner/pi-coding-agent";
import type { AppDatabase } from "../database";
import type { SkillImportResult, RuntimeSkillEntry } from "../skill-types.js";
import { estimateTokens, parseFrontmatterMarkdown, extractRuntimeSemantics } from "../skill-format.js";
import { resolveTokenBudget } from "../skill-types.js";
/**
* Role/department keywords → builtin skill category manifest. Centralized at
* module scope so future changes do not silently drift from the category
* names used by `meta.builtinCategory` on disk.
*
* Category names mirror SKILL.md directory names (mixed-case is
* load-bearing; e.g. "iOS-Dev" and "Video-maker" match file system layout).
*/
const ROLE_SKILL_CATEGORIES: Record<string, readonly 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"],
};
import type { SkillImportResult, ProjectedSkill } from "../skill-types.js";
interface RuntimeDeps {
db: AppDatabase;
@@ -40,55 +19,35 @@ interface RuntimeDeps {
export function createSkillRuntime({ db, logger, skillsRoot, skillFilesDir, importFromLocal }: RuntimeDeps) {
/**
* Build the list of RuntimeSkillEntry for adapter injection.
* Applies token budget enforcement: skills sorted by creation date,
* accumulated until budget exceeded, then skipped with warning.
* Project all company skills through pi-coding-agent's progressive-disclosure
* loader. Every agent in the company sees the full catalog — descriptions
* only, full SKILL.md loaded on-demand by the agent via the `read` tool.
*
* The agentId / connectorId parameters are retained for signature
* compatibility with the orchestrator but are intentionally unused: pi's
* model has no per-agent filtering and no per-connector token budget.
*/
function listRuntimeSkillEntries(
companyId: string,
agentId: string,
adapterType: string,
): RuntimeSkillEntry[] {
const allSkills = db.listCompanySkills(companyId);
const agentAssignments = db.listAgentSkillAssignments(agentId);
const skills = allSkills.filter((s) => agentAssignments.includes(s.id));
_agentId: string,
_connectorId: string,
): ProjectedSkill[] {
const companySkills = db.listCompanySkills(companyId);
const skillPaths = companySkills.map((s) => skillFilesDir(companyId, s.slug));
const { skills, diagnostics } = loadSkills({ skillPaths, includeDefaults: false });
const sorted = [...skills].sort(
(a, b) => a.createdAt.localeCompare(b.createdAt),
);
const budget = resolveTokenBudget(adapterType);
let accumulated = 0;
const entries: RuntimeSkillEntry[] = [];
for (const skill of sorted) {
const tokenEstimate = estimateTokens(skill.markdown);
if (accumulated + tokenEstimate > budget) {
logger.warn(
`Token budget exceeded for ${adapterType}: skipping skill "${skill.name}" (${tokenEstimate} tokens, budget ${budget}, used ${accumulated})`,
);
continue;
for (const diag of diagnostics) {
if (diag.type === "warning") {
logger.warn(`[skills] ${diag.message} (${diag.path})`);
}
accumulated += tokenEstimate;
const { frontmatter } = parseFrontmatterMarkdown(skill.markdown);
const semantics = extractRuntimeSemantics(frontmatter);
entries.push({
runtimeName: skill.slug,
sourcePath: skillFilesDir(companyId, skill.slug),
name: skill.name,
tokenEstimate,
allowedTools: semantics.allowedTools,
hooks: semantics.hooks,
contextMode: semantics.contextMode,
isAgent: semantics.isAgent,
effort: semantics.effort,
whenToUse: semantics.whenToUse,
});
}
return entries;
return skills.map((s) => ({
name: s.name,
description: s.description,
filePath: s.filePath,
baseDir: s.baseDir,
}));
}
/**
@@ -162,7 +121,14 @@ export function createSkillRuntime({ db, logger, skillsRoot, skillFilesDir, impo
const { readdirSync, statSync: statSyncLocal } = await import("node:fs");
categories = readdirSync(builtinDir).filter(name => {
if (name.startsWith(".")) return false;
try { return statSyncLocal(join(builtinDir, name)).isDirectory(); } catch { return false; }
try {
return statSyncLocal(join(builtinDir, name)).isDirectory();
} catch (error) {
logger.warn(
`[skills] statSync failed for builtin entry ${name}: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
}
});
} catch {
logger.warn(`[skills] Failed to read builtin skills directory: ${builtinDir}`);
@@ -182,7 +148,11 @@ export function createSkillRuntime({ db, logger, skillsRoot, skillFilesDir, impo
existingMeta.sourceKind = "builtin";
db.updateCompanySkillMetadata(companyId, result.skillId, JSON.stringify(existingMeta));
}
} catch { /* non-fatal metadata update */ }
} catch (error) {
logger.warn(
`[skills] metadata update failed for skill ${result.skillId} (category ${category}): ${error instanceof Error ? error.message : String(error)}`,
);
}
}
allResults.push(...results);
logger.info(`[skills] Seeded ${results.length} builtin skill(s) from category: ${category}`);
@@ -195,57 +165,9 @@ export function createSkillRuntime({ db, logger, skillsRoot, skillFilesDir, impo
return allResults;
}
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;
}
function assignRoleSkillsToAgent(companyId: string, agentId: string, role: string, department?: string | null): number {
const allSkills = db.listCompanySkills(companyId);
let assigned = 0;
const normalizedRole = role.toLowerCase();
const normalizedDept = department?.toLowerCase() ?? "";
let categories = ROLE_SKILL_CATEGORIES[normalizedRole] ?? ["basic-for-all"];
for (const [key, cats] of Object.entries(ROLE_SKILL_CATEGORIES)) {
if (normalizedDept.includes(key) || normalizedRole.includes(key)) {
categories = [...new Set([...categories, ...cats])];
}
}
if (normalizedRole === "ceo" || normalizedRole.includes("chief")) {
categories = ROLE_SKILL_CATEGORIES.ceo ?? ["basic-for-all"];
}
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 {
listRuntimeSkillEntries,
scanWorkspaceSkills,
seedBuiltinSkills,
assignBasicSkillsToAgent,
assignRoleSkillsToAgent,
};
}
+7 -23
View File
@@ -202,17 +202,7 @@ export function sweepStaleReviews(
const isCofounderMode = deps.getCompanyExecutionMode(companyId) === "ai_cofounder";
let escalatedCount = 0;
const reviewTasks = deps.db.queryAll<{
id: string;
assigneeAgentId: string | null;
updatedAt: string;
title: string;
}>(
`SELECT id, assignee_agent_id as assigneeAgentId, updated_at as updatedAt, title
FROM tasks
WHERE company_id = ? AND status = 'in_review'`,
companyId,
);
const reviewTasks = deps.db.listReviewTasksForSweep(companyId);
for (const task of reviewTasks) {
const reviewAge = now - new Date(task.updatedAt).getTime();
@@ -227,26 +217,20 @@ export function sweepStaleReviews(
const chain = deps.db.getChainOfCommand(reviewer.id);
const escalationTarget = chain.length > 0 ? chain[0] : null;
const pendingApprovals = deps.db.queryAll<{ id: string; type: string }>(
`SELECT id, type FROM approvals
WHERE company_id = ? AND related_task_id = ? AND state = 'pending'
LIMIT 1`,
companyId,
task.id,
);
const pendingApproval = deps.db.findPendingApprovalForTask(companyId, task.id);
// In ai_cofounder mode, ALL approvals require human decision — never auto-advance.
if (isCofounderMode && pendingApprovals.length > 0) {
if (isCofounderMode && pendingApproval) {
deps.notify({
title: "Your Review Required",
body: `"${task.title}" is waiting for your review. AI Co-Founder mode requires human sign-off.`,
urgency: "critical",
navigation: { section: "approvals", entityId: pendingApprovals[0]?.id },
navigation: { section: "approvals", entityId: pendingApproval.id },
batchKey: "cofounder_review_needed",
});
deps.logger.info("[sweepStaleReviews] Skipping auto-approval for co-founder task", {
taskId: task.id,
approvalType: pendingApprovals[0]?.type,
approvalType: pendingApproval.type,
});
continue;
}
@@ -283,13 +267,13 @@ export function sweepStaleReviews(
// D-10: Auto-approve any pending approval linked to this task.
// In ai_cofounder mode, protected approval types are already handled above (continue).
for (const approval of pendingApprovals) {
if (pendingApproval) {
try {
deps.resolveApprovalDecision({
db: deps.db,
handleApprovalResolved: deps.handleApprovalResolved,
companyId,
approvalId: approval.id,
approvalId: pendingApproval.id,
state: "approved",
decisionNote: "Auto-approved: review timeout exceeded 30 minutes.",
detail: "Auto-approved by review timeout escalation",
+12 -1
View File
@@ -3,11 +3,19 @@ import { closeSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs
import { join } from "node:path";
import { safeStorage } from "electron";
export interface SecretVaultOptions {
/** When true, the AES-GCM file-key fallback is refused even if the env flag
* is set. Packaged main-process callers should pass `app.isPackaged`. */
isPackaged?: boolean;
}
export class SecretVault {
private readonly keyPath: string;
private readonly isPackaged: boolean;
constructor(private readonly profileDir: string) {
constructor(private readonly profileDir: string, options: SecretVaultOptions = {}) {
this.keyPath = join(profileDir, "vault.key");
this.isPackaged = options.isPackaged === true;
}
getBackendLabel(): string {
@@ -104,6 +112,9 @@ export class SecretVault {
}
private allowInsecureVaultFallback() {
// Hard gate: the AES-GCM fallback is a dev-only escape hatch. Packaged
// builds must refuse it regardless of the env flag.
if (this.isPackaged) return false;
return process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT === "true";
}
}
+12 -2
View File
@@ -151,7 +151,13 @@ export function createWorkerService({
// exits — otherwise the pending timer keeps the event loop alive.
const ref = worker;
const killTimer = setTimeout(() => {
try { ref.kill(); } catch { /* already dead */ }
try {
ref.kill();
} catch (error) {
logger.debug(
`[worker-service] worker kill() failed (likely already dead): ${error instanceof Error ? error.message : String(error)}`,
);
}
}, 2000);
ref.once("exit", () => clearTimeout(killTimer));
worker = null;
@@ -180,7 +186,11 @@ export function createWorkerService({
db.releaseTaskLock(run.taskId, run.id);
}
}
} catch { /* best effort during shutdown */ }
} catch (error) {
logger.warn(
`[worker-service] interrupted-run cleanup during shutdown failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return {
+15 -4
View File
@@ -76,8 +76,10 @@ export function handleFailureEscalation(
`[escalation] Escalated task ${run.taskId} to manager ${chain[0].name} (${chain[0].id})`,
);
}
} catch {
// Chain lookup failed (agent deleted)
} catch (error) {
logger.warn(
`[escalation] Chain-of-command lookup failed for agent ${run.agentId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (!escalated) {
@@ -90,7 +92,11 @@ export function handleFailureEscalation(
`[escalation] Escalated task ${run.taskId} to CEO ${ceo.name} (${ceo.id})`,
);
}
} catch { /* CEO lookup failed */ }
} catch (error) {
logger.warn(
`[escalation] CEO lookup failed for company ${run.companyId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
if (!escalated) {
@@ -111,8 +117,13 @@ export function handleFailureEscalation(
export function handleSuccessReset(
db: EscalationDeps["db"],
taskId: string,
logger?: EscalationDeps["logger"],
): void {
try {
db.resetTaskRetryCount(taskId);
} catch { /* best effort */ }
} catch (error) {
logger?.warn(
`[escalation] resetTaskRetryCount failed for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
@@ -26,7 +26,10 @@ export function finalizeRun(
let run: RunRecord;
try {
run = db.getRun(event.runId);
} catch {
} catch (error) {
logger.warn(
`[finalizeRun] getRun(${event.runId}) failed; run may have been deleted: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
}
@@ -55,7 +58,10 @@ export function finalizeRun(
let task: TaskRecord | null = null;
try {
task = db.getTask(run.taskId);
} catch {
} catch (error) {
logger.warn(
`[finalizeRun] getTask(${run.taskId}) failed; task may have been deleted: ${error instanceof Error ? error.message : String(error)}`,
);
task = null;
}
@@ -120,7 +126,7 @@ export function emitAndNotifyCompletion(
deps: HandlerDeps,
rfCtx: RunFinishedContext,
): void {
const { db, emitEvent, notify } = deps;
const { db, emitEvent, notify, logger } = deps;
const { run, task, runOutcome } = rfCtx;
emitEvent({ type: "run-updated", runId: run.id, action: "completed" });
@@ -143,7 +149,10 @@ export function emitAndNotifyCompletion(
let updatedAgent: ReturnType<HandlerDeps["db"]["getAgent"]> | null = null;
try {
updatedAgent = db.getAgent(run.agentId);
} catch {
} catch (error) {
logger.warn(
`[emitAndNotifyCompletion] getAgent(${run.agentId}) failed; skipping budget-paused notification: ${error instanceof Error ? error.message : String(error)}`,
);
updatedAgent = null;
}
@@ -177,14 +186,11 @@ export function runPostCompletionAutomation(
const { destroyed, MAX_RETRY_ATTEMPTS, RETRY_COOLDOWN_MS, pendingRetryTimers } = context;
const { run, task } = rfCtx;
// Policy-based incident check after run completion (Phase 5)
// Policy-based incident check after run completion (Phase 5). Reuse the
// `task` already resolved in finalizeRun instead of double-fetching with
// its own catch-and-swallow; if task is null the project-scope is null.
if (budgetService && typeof event.costUsd === "number" && Number.isFinite(event.costUsd)) {
try {
const taskForBudget = db.getTask(run.taskId);
budgetService.checkAndCreateIncidents(run.companyId, run.agentId, taskForBudget?.projectId ?? null);
} catch (err) {
logger.error(`[budget] Incident check failed: ${err instanceof Error ? err.message : String(err)}`);
}
budgetService.checkAndCreateIncidents(run.companyId, run.agentId, task?.projectId ?? null);
}
if ((event.status === "failed" || event.status === "timed_out") && !isQuitting() && !destroyed) {
@@ -252,7 +258,7 @@ export function runPostCompletionAutomation(
// Reset retry count on successful completion
if (event.status === "succeeded") {
handleSuccessReset(db, run.taskId);
handleSuccessReset(db, run.taskId, logger);
}
return { hadDeferredWakes };
@@ -294,13 +300,17 @@ export function triggerSelfWake(
if (chain.length > 0) {
wakeAgentIfPossible(chain[0].id, run.companyId, "report_stuck");
}
} catch {
/* best effort */
} catch (error) {
logger.warn(
`[self-wake] chain-of-command lookup failed for agent ${run.agentId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
} else {
wakeAgentIfPossible(run.agentId, run.companyId, "task_continuation");
}
} catch {
// Best-effort self-wake; agent will pick up work on the next heartbeat cycle.
} catch (error) {
logger.warn(
`[self-wake] self-wake evaluation failed for agent ${run.agentId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
+6
View File
@@ -92,6 +92,12 @@ export function handleWorkerExit(
const recoverySnapshot = db.listSnapshot();
for (const candidate of requeueCandidates) {
try {
// The crashed run may still own a workspace lock acquired during the
// original dispatch; release it before re-dispatch so the replay
// does not self-collide with "Workspace is locked by another run".
if (candidate.workspaceId) {
db.releaseWorkspaceLock(candidate.workspaceId, candidate.id);
}
const task = db.getTask(candidate.taskId);
const agent = db.getAgent(candidate.agentId);
const connector = db.getConnector(agent.connectorId);
+33 -18
View File
@@ -37,33 +37,39 @@ function enqueue(job: RunJob) {
status: "queued",
message: "Run queued in orchestration worker.",
});
void maybeStartQueuedRuns();
maybeStartQueuedRuns();
}
async function maybeStartQueuedRuns() {
const readyJobs = [...queue];
for (const job of readyJobs) {
if (activeByAgent.has(job.agentId)) continue;
/**
* Walks the queue in-place and dispatches every job whose agent + workspace
* are not currently busy. Claims (activeByAgent / activeByWorkspace) are
* written BEFORE the job is removed from the queue and BEFORE `startRun`
* fires — this is the atomic point of commit for orchestrator ordering.
* Hardens against a future refactor that adds an `await` before the claim
* inside `startRun`, which would open a re-entrancy window.
*/
function maybeStartQueuedRuns() {
let i = 0;
while (i < queue.length) {
const job = queue[i];
if (activeByAgent.has(job.agentId)) { i += 1; continue; }
const lockKey = workspaceKey(job);
if (lockKey && activeByWorkspace.has(lockKey)) continue;
const index = queue.findIndex((entry) => entry.runId === job.runId);
if (index >= 0) queue.splice(index, 1);
void startRun(job);
if (lockKey && activeByWorkspace.has(lockKey)) { i += 1; continue; }
activeByAgent.set(job.agentId, job.runId);
if (lockKey) activeByWorkspace.set(lockKey, job.runId);
queue.splice(i, 1);
void startRun(job, lockKey);
}
}
async function startRun(job: RunJob) {
const lockKey = workspaceKey(job);
activeByAgent.set(job.agentId, job.runId);
if (lockKey) activeByWorkspace.set(lockKey, job.runId);
async function startRun(job: RunJob, lockKey: string | null) {
try {
await runner.run(job);
} finally {
cancelledRuns.delete(job.runId);
activeByAgent.delete(job.agentId);
if (lockKey) activeByWorkspace.delete(lockKey);
void maybeStartQueuedRuns();
maybeStartQueuedRuns();
}
}
@@ -109,8 +115,17 @@ parentPort.on("message", (event) => {
return;
}
if (message.type === "shutdown") {
runner.cancelAll();
rpc.cancelAll("Worker shutting down.");
process.exit(0);
void (async () => {
// Graceful drain: abort every active run and await its promise
// settlement so that final `run-finished` messages and
// `persistMessage` RPCs reach the parent before we exit. Bounded by
// a 5s budget so a stuck run does not wedge the shutdown handshake
// (the parent has its own kill-timer as a safety net).
const drainBudgetMs = 5_000;
const timeout = new Promise<void>((resolve) => setTimeout(resolve, drainBudgetMs));
await Promise.race([runner.cancelAll(), timeout]);
rpc.cancelAll("Worker shutting down.");
process.exit(0);
})();
}
});
+19 -4
View File
@@ -19,7 +19,6 @@ export interface RunJob {
sessionParams: Record<string, unknown> | null;
systemPrompt: string | null;
trigger: string | null;
managedSkills: Array<{ runtimeName: string; sourcePath: string }>;
taskSessionId: string;
executionMode: "ai_ceo" | "ai_cofounder";
}
@@ -80,7 +79,16 @@ export function buildRedactor(env: Record<string, string>): (text: string) => st
};
}
/** Pushes extra env vars onto process.env and returns a restore function. */
/**
* Pushes extra env vars onto `process.env` and returns a restore function
* that is **infallible** — each individual restore is guarded so a single
* malformed key cannot leave a partial restore that keeps a provider
* secret in the worker's env for the next run. Intended for short-lived
* turn-scope env (`AGENT_COMPANY_*`, connector secrets). The worker runs
* one agent at a time, so this is safe for the current scheduler; do not
* convert it to concurrent run isolation — pass per-spawn env at the
* child-process level instead.
*/
export function applyEnvOverrides(overrides: Record<string, string>): () => void {
const saved: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(overrides)) {
@@ -89,8 +97,15 @@ export function applyEnvOverrides(overrides: Record<string, string>): () => void
}
return () => {
for (const [key, previous] of Object.entries(saved)) {
if (previous === undefined) delete process.env[key];
else process.env[key] = previous;
try {
if (previous === undefined) delete process.env[key];
else process.env[key] = previous;
} catch {
// Individual restore failure is never fatal — move on so the
// remaining keys still revert. Should never happen in practice
// (the Node env-var assignment is synchronous and infallible for
// string values), but the guard lets the outer finally complete.
}
}
};
}
+7 -2
View File
@@ -42,9 +42,14 @@ export async function handleWorkerMessage(
case "run-status":
handleRunStatus(event, deps, context);
return;
case "run-finished":
handleRunFinished(event, deps, context);
case "run-finished": {
try {
handleRunFinished(event, deps, context);
} finally {
deps.getRuntimeBridge?.()?.releaseRunResources(event.runId);
}
return;
}
case "agent-event": {
const bridge = deps.getRuntimeBridge?.();
if (bridge) {
+21 -3
View File
@@ -3,17 +3,33 @@ import type { ChildMessage } from "./worker-helpers";
interface PendingRpc {
resolve: (value: { ok: true; result: unknown } | { ok: false; error: string }) => void;
timer: NodeJS.Timeout;
}
const DEFAULT_RPC_TIMEOUT_MS = 30_000;
export class WorkerRpcClient {
private readonly pending = new Map<string, PendingRpc>();
constructor(private readonly post: (message: ChildMessage) => void) {}
constructor(
private readonly post: (message: ChildMessage) => void,
private readonly timeoutMs: number = DEFAULT_RPC_TIMEOUT_MS,
) {}
invoke(runId: string, method: string, payload: unknown): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> {
invoke(
runId: string,
method: string,
payload: unknown,
): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> {
const rpcId = randomUUID();
return new Promise((resolve) => {
this.pending.set(rpcId, { resolve });
const timer = setTimeout(() => {
const pending = this.pending.get(rpcId);
if (!pending) return;
this.pending.delete(rpcId);
pending.resolve({ ok: false, error: `RPC ${method} timed out after ${this.timeoutMs}ms` });
}, this.timeoutMs);
this.pending.set(rpcId, { resolve, timer });
this.post({ type: "rpc-request", rpcId, runId, method, payload });
});
}
@@ -21,12 +37,14 @@ export class WorkerRpcClient {
resolve(rpcId: string, response: { ok: true; result: unknown } | { ok: false; error: string }): void {
const pending = this.pending.get(rpcId);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(rpcId);
pending.resolve(response);
}
cancelAll(reason: string): void {
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer);
pending.resolve({ ok: false, error: reason });
this.pending.delete(id);
}
+31 -35
View File
@@ -23,6 +23,7 @@ interface RunnerDeps {
export class WorkerRunner {
private readonly abortControllers = new Map<string, AbortController>();
private readonly runPromises = new Map<string, Promise<void>>();
constructor(private readonly deps: RunnerDeps) {}
@@ -31,21 +32,37 @@ export class WorkerRunner {
if (controller) controller.abort();
}
cancelAll(): void {
/**
* Aborts every in-flight run and returns a promise that resolves once
* each `run()` has settled (success, failure, or abort). Callers (the
* worker shutdown handler) must `await` this before `process.exit` so
* pending `persistMessage` RPCs and the final `run-finished` post reach
* the parent before the worker dies.
*/
async cancelAll(): Promise<void> {
for (const controller of this.abortControllers.values()) {
controller.abort();
}
const pending = [...this.runPromises.values()];
this.abortControllers.clear();
await Promise.allSettled(pending);
}
async run(job: RunJob): Promise<void> {
run(job: RunJob): Promise<void> {
const promise = this.runImpl(job).finally(() => {
this.runPromises.delete(job.runId);
});
this.runPromises.set(job.runId, promise);
return promise;
}
private async runImpl(job: RunJob): Promise<void> {
const { post, rpc, isCancelled } = this.deps;
const redact = buildRedactor(job.env);
const abortController = new AbortController();
this.abortControllers.set(job.runId, abortController);
post({ type: "run-status", runId: job.runId, status: "running", message: `Running agent ${job.agentName}.` });
post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[runner] starting run for ${job.agentName} (${job.connectorId}/${job.model ?? "?"})\n` });
let resolvedModelId: string | null = job.model;
let finalUsage: { inputTokens: number | null; outputTokens: number | null; costUsd: number | null } = {
@@ -55,6 +72,14 @@ export class WorkerRunner {
};
let errorMessage: string | null = null;
// Inject the AgentCompany job env (AGENT_COMPANY_API_URL / API_KEY /
// AGENT_ID / COMPANY_ID / RUN_ID / TASK_ID / WAKE_REASON + provider
// secret bindings) into the worker's process.env BEFORE Agent
// construction, so the agent's bash-tool children can call back into
// our HTTP API via fetch(process.env.AGENT_COMPANY_API_URL). Restored
// in the outer finally regardless of success / abort / throw.
const restoreJobEnv = applyEnvOverrides(job.env);
try {
if (!job.model) {
throw new Error(`Run ${job.runId} has no model selected.`);
@@ -73,8 +98,7 @@ export class WorkerRunner {
}
const prepared = prepareResponse.result as PrepareRunResult;
post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[runner] prepareRun ok, resolving model\n` });
const restoreEnv = applyEnvOverrides(prepared.extraEnv);
const restoreProviderEnv = applyEnvOverrides(prepared.extraEnv);
try {
const model = resolveModel(prepared.provider, job.model);
if (!model) {
@@ -82,7 +106,6 @@ export class WorkerRunner {
}
resolvedModelId = model.id;
const extraHeaders = resolveExtraStreamHeaders(prepared.provider, prepared.apiKey);
post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[runner] model=${model.id} provider=${prepared.provider} — constructing Agent\n` });
const cwd = job.workspacePath ?? process.cwd();
const tools = createCodingTools(cwd) as never;
@@ -100,36 +123,10 @@ export class WorkerRunner {
apiKey: prepared.apiKey,
headers: { ...(options?.headers ?? {}), ...(extraHeaders ?? {}) },
}),
beforeToolCall: async (context) => {
const verdict = await rpc.invoke(job.runId, "beforeToolCall", {
agentId: job.agentId,
companyId: job.companyId,
taskId: job.taskId,
toolCallId: context.toolCall.id,
toolName: context.toolCall.name,
args: context.args,
});
if (verdict.ok) {
return verdict.result as { block?: boolean; reason?: string } | undefined;
}
return { block: true, reason: verdict.error };
},
afterToolCall: async (context) => {
await rpc.invoke(job.runId, "afterToolCall", {
toolCallId: context.toolCall.id,
toolName: context.toolCall.name,
isError: context.isError,
});
return undefined;
},
});
const unsubscribe = agent.subscribe(async (event) => {
const timestamp = new Date().toISOString();
// Surface every pi AgentEvent type as a raw Console probe so the
// operator can see the live event stream even when projection
// misses a type or the agent is stuck inside a particular phase.
post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[pi-event] ${event.type}\n` });
post({
type: "agent-event",
runId: job.runId,
@@ -166,22 +163,21 @@ export class WorkerRunner {
content: [{ type: "text", text: promptContent }],
timestamp: Date.now(),
};
post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[runner] calling agent.prompt (${promptContent.length} chars)\n` });
const runPromise = agent.prompt(promptMessage);
abortController.signal.addEventListener("abort", () => agent.abort(), { once: true });
await runPromise;
post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[runner] agent.prompt resolved\n` });
} finally {
unsubscribe();
}
} finally {
restoreEnv();
restoreProviderEnv();
}
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
post({ type: "run-log", runId: job.runId, stream: "stderr", chunk: redact(errorMessage) });
} finally {
this.abortControllers.delete(job.runId);
restoreJobEnv();
}
const status: RunStatus = isCancelled(job.runId)
+1 -1
View File
@@ -50,7 +50,7 @@ export const workerMessageSchema = z.union([
type: z.literal("rpc-request"),
rpcId: z.string(),
runId: z.string().uuid(),
method: z.enum(["beforeToolCall", "afterToolCall", "persistMessage", "prepareRun"]),
method: z.enum(["persistMessage", "prepareRun"]),
payload: z.unknown(),
}),
]);
+18 -19
View File
@@ -107,29 +107,19 @@ const api: DesktopApi = {
getBudgetStatus: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getBudgetStatus, { companyId }),
recoveryRetry: (taskId: string, agentId: string, companyId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.recoveryRetry, { taskId, agentId, companyId }),
recoveryDismiss: (taskId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.recoveryDismiss, { taskId }),
recoveryFail: (taskId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.recoveryFail, { taskId }),
recoveryDismiss: (taskId: string, companyId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.recoveryDismiss, { taskId, companyId }),
recoveryFail: (taskId: string, companyId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.recoveryFail, { taskId, companyId }),
getTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.getTask, input),
getAgent: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgent, input),
getRun: (input) => ipcRenderer.invoke(IPC_CHANNELS.getRun, input),
getApproval: (input) => ipcRenderer.invoke(IPC_CHANNELS.getApproval, input),
listConfigRevisions: (companyId: string, agentId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.listConfigRevisions, { companyId, agentId }),
getConfigRevision: (companyId: string, revisionId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.getConfigRevision, { companyId, revisionId }),
rollbackConfigRevision: (companyId: string, agentId: string, revisionId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.rollbackConfigRevision, { companyId, agentId, revisionId }),
listRunEvents: (companyId: string, runId: string, opts?: { offset?: number; limit?: number }) =>
ipcRenderer.invoke(IPC_CHANNELS.listRunEvents, { companyId, runId, ...opts }),
getAgentPermissions: (agentId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.getAgentPermissions, { agentId }),
setAgentPermission: (companyId: string, agentId: string, permissionKey: string, enabled: boolean) =>
ipcRenderer.invoke(IPC_CHANNELS.setAgentPermission, { companyId, agentId, permissionKey, enabled }),
getHeartbeatPolicy: (agentId: string, companyId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.getHeartbeatPolicy, { companyId, agentId }),
setHeartbeatPolicy: (companyId: string, agentId: string, policy: Record<string, unknown>) =>
setHeartbeatPolicy: (companyId, agentId, policy) =>
ipcRenderer.invoke(IPC_CHANNELS.setHeartbeatPolicy, { companyId, agentId, ...policy }),
getRuntimeState: (agentId: string, companyId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.getRuntimeState, { companyId, agentId }),
@@ -159,10 +149,19 @@ const api: DesktopApi = {
};
},
platform: process.platform as "win32" | "darwin" | "linux",
windowMinimize: () => ipcRenderer.invoke(IPC_CHANNELS.windowMinimize),
windowMaximize: () => ipcRenderer.invoke(IPC_CHANNELS.windowMaximize),
windowClose: () => ipcRenderer.invoke(IPC_CHANNELS.windowClose),
windowIsMaximized: () => ipcRenderer.invoke(IPC_CHANNELS.windowIsMaximized) as Promise<boolean>,
windowMinimize: async () => {
await ipcRenderer.invoke(IPC_CHANNELS.windowMinimize);
},
windowMaximize: async () => {
await ipcRenderer.invoke(IPC_CHANNELS.windowMaximize);
},
windowClose: async () => {
await ipcRenderer.invoke(IPC_CHANNELS.windowClose);
},
windowIsMaximized: async () => {
const result = (await ipcRenderer.invoke(IPC_CHANNELS.windowIsMaximized)) as { ok: true; data: boolean } | { ok: false };
return result.ok ? result.data : false;
},
// Credentials (pi-mono)
listCredentials: () => ipcRenderer.invoke(IPC_CHANNELS.credentialsList),
saveCredential: (input) => ipcRenderer.invoke(IPC_CHANNELS.credentialsSave, input),
+4 -1
View File
@@ -2,7 +2,7 @@ import { useCallback, useMemo, type ReactNode } from "react";
import { CircleNotch, TerminalWindow } from "./components/ui/icons";
import { LocaleContext, TranslationContext, type TranslationFn } from "./i18n";
import type { AppLocale } from "@shared/locale";
import { CompanyOnboarding } from "./components/CompanyOnboarding";
import { CompanyOnboarding } from "./components/company-onboarding/OnboardingWizard";
import TitleBar from "./components/TitleBar";
import ToastContainer from "./components/Toast";
import { SidebarView } from "./views/SidebarView";
@@ -14,6 +14,7 @@ import { useUIController } from "./hooks/useUIController";
import { useCompanyController } from "./hooks/useCompanyController";
import { useCommandPaletteHotkeys } from "./hooks/useCommandPaletteHotkeys";
import { AppActionsContext, type AppActions } from "./hooks/useAppActions";
import { SectionContext } from "./hooks/useSectionContext";
import { unwrap } from "./lib/desktop";
function Shell({
@@ -157,6 +158,7 @@ export function App() {
<LocaleContext.Provider value={ui.locale}>
<TranslationContext.Provider value={ui.t}>
<AppActionsContext.Provider value={appActions}>
<SectionContext.Provider value={company.sectionContextValue}>
<div className="flex h-[100dvh] flex-col overflow-hidden bg-background">
{showCustomTitleBar && <TitleBar />}
<div className={`grid min-h-0 flex-1 overflow-hidden transition-[grid-template-columns] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${ui.sidebarCollapsed ? "grid-cols-[56px_minmax(0,1fr)]" : "grid-cols-[220px_minmax(0,1fr)]"}`}>
@@ -247,6 +249,7 @@ export function App() {
<ToastContainer />
</div>
</div>
</SectionContext.Provider>
</AppActionsContext.Provider>
</TranslationContext.Provider>
</LocaleContext.Provider>
@@ -1,2 +0,0 @@
// Backward-compatible re-exports from decomposed onboarding components
export { CompanyOnboarding } from "./company-onboarding/OnboardingWizard";
+6 -4
View File
@@ -1,7 +1,9 @@
import { useState, useEffect, useCallback } from "react";
import { Copy, Minus, Square, X } from "./ui/icons";
import { useT } from "../i18n";
export default function TitleBar() {
const t = useT();
const [maximized, setMaximized] = useState(false);
const refreshMaximized = useCallback(() => {
@@ -26,7 +28,7 @@ export default function TitleBar() {
<div className="flex h-full">
<button
type="button"
aria-label="Minimize"
aria-label={t("titlebar.minimize")}
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-muted hover:text-foreground"
onClick={() => void window.agentCompany.windowMinimize()}
>
@@ -34,7 +36,7 @@ export default function TitleBar() {
</button>
<button
type="button"
aria-label={maximized ? "Restore" : "Maximize"}
aria-label={maximized ? t("titlebar.restore") : t("titlebar.maximize")}
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-muted hover:text-foreground"
onClick={() => {
void window.agentCompany.windowMaximize().then(refreshMaximized);
@@ -48,8 +50,8 @@ export default function TitleBar() {
</button>
<button
type="button"
aria-label="Close"
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-red-500 hover:text-white"
aria-label={t("titlebar.close")}
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-destructive hover:text-primary-foreground"
onClick={() => void window.agentCompany.windowClose()}
>
<X size={14} weight="bold" />
-1
View File
@@ -1 +0,0 @@
export { AutomationPanel } from "./automation/AutomationPanel"
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { ArrowRight, ArrowsClockwise, Lightning, Plus, Robot, X } from "../ui/icons"
import type { AutomationAction, AutomationRuleRecord, AutomationStatus, AutomationTrigger, WorkflowPipelineRecord } from "@shared/types"
import { ActionButton, ConfirmDialog } from "../ui"
import { useT } from "../../i18n"
import { ActionButton, ConfirmDialog, Card, Eyebrow } from "../ui"
import { useT, type TranslationFn } from "../../i18n"
import { triggerConfig, actionConfig, type AutomationLogEntry } from "./automation-config"
import { timeAgo } from "../../lib/formatters"
import { RuleCard } from "./RuleCard"
@@ -11,9 +11,9 @@ import { RuleFormModal } from "./RuleEditor"
import { WorkflowCard } from "./WorkflowList"
import { WorkflowFormModal } from "./WorkflowEditor"
function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) {
function AutomationActivityLog({ entries, t }: { entries: AutomationLogEntry[]; t: TranslationFn }) {
if (entries.length === 0) {
return <div className="py-6 text-center text-[13px] text-muted-foreground">No activity yet</div>
return <div className="py-6 text-center text-[13px] text-muted-foreground">{t("auto.activity.noActivity")}</div>
}
return (
<div className="space-y-1">
@@ -48,7 +48,7 @@ function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) {
)
}
function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleRecord[]; companyId: string; onRefresh: () => void }) {
function RulesPanel({ rules, companyId, onRefresh, t }: { rules: AutomationRuleRecord[]; companyId: string; onRefresh: () => void; t: TranslationFn }) {
const [showCreate, setShowCreate] = useState(false); const [editRule, setEditRule] = useState<AutomationRuleRecord | null>(null); const [delTarget, setDelTarget] = useState<AutomationRuleRecord | null>(null)
const [actLog, setActLog] = useState<AutomationLogEntry[]>([]); const [activeTab, setActiveTab] = useState<"rules"|"activity">("rules"); const [filterStatus, setFilterStatus] = useState<AutomationStatus|"all">("all")
const [actionError, setActionError] = useState<string | null>(null)
@@ -56,8 +56,8 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
useEffect(() => { void window.agentCompany.getAutomationLog(companyId).then((r) => { if (r.ok) setActLog(r.data) }) }, [companyId, rules])
const filtered = useMemo(() => filterStatus === "all" ? rules : rules.filter((r) => r.status === filterStatus), [rules, filterStatus])
const counts = useMemo(() => { let active = 0, paused = 0, error = 0; for (const r of rules) { if (r.status === "active") active++; else if (r.status === "paused") paused++; else if (r.status === "error") error++; } return { total: rules.length, active, paused, error } }, [rules])
const handleToggle = useCallback(async (rule: AutomationRuleRecord) => { setActionError(null); try { const r = await window.agentCompany.toggleAutomationRule({ id: rule.id, companyId, status: (rule.status === "active" ? "paused" : "active") as "active"|"paused" }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed") } }, [companyId, onRefresh])
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteAutomationRule({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed"); setDelTarget(null) } }, [delTarget, companyId, onRefresh])
const handleToggle = useCallback(async (rule: AutomationRuleRecord) => { setActionError(null); try { const r = await window.agentCompany.toggleAutomationRule({ id: rule.id, companyId, status: (rule.status === "active" ? "paused" : "active") as "active"|"paused" }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")) } }, [companyId, onRefresh, t])
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteAutomationRule({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")); setDelTarget(null) } }, [delTarget, companyId, onRefresh, t])
return (
<div className="space-y-5">
@@ -75,25 +75,25 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
<Lightning size={20} weight="fill" />
</div>
<div>
<h2 className="text-[16px] font-bold text-foreground">Automation Rules</h2>
<h2 className="text-[16px] font-bold text-foreground">{t("auto.rules.title")}</h2>
<p className="text-[12px] text-muted-foreground">
{counts.active} active, {counts.paused} paused{counts.error > 0 ? `, ${counts.error} errored` : ""}
{t("auto.rules.summaryActive").replace("{n}", String(counts.active))},{t("auto.rules.summaryPaused").replace("{n}", String(counts.paused))}{counts.error > 0 ? `,${t("auto.rules.summaryErrored").replace("{n}", String(counts.error))}` : ""}
</p>
</div>
</div>
<ActionButton label="New Rule" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" />
<ActionButton label={t("auto.rules.newRule")} onClick={() => setShowCreate(true)} icon={Plus} tone="accent" />
</div>
{rules.length > 0 ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{[
{ l: "Total Rules", v: counts.total, accent: false },
{ l: "Total Executions", v: rules.reduce((s, r) => s + r.executionCount, 0), accent: true },
{ l: "Recent Activity", v: actLog.length, accent: true },
{ l: t("auto.rules.totalRules"), v: counts.total, accent: false },
{ l: t("auto.rules.totalExecutions"), v: rules.reduce((s, r) => s + r.executionCount, 0), accent: true },
{ l: t("auto.rules.recentActivity"), v: actLog.length, accent: true },
].map((s) => (
<div key={s.l} className="rounded-lg border border-border bg-card px-4 py-3.5">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{s.l}</div>
<Card key={s.l} padding="sm" className="!py-3.5">
<Eyebrow>{s.l}</Eyebrow>
<div className={`text-[26px] font-bold tabular-nums ${s.accent ? "text-primary" : ""}`}>{s.v}</div>
</div>
</Card>
))}
</div>
) : null}
@@ -103,45 +103,51 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
onClick={() => setActiveTab("rules")}
className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "rules" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
>
Rules ({rules.length})
{t("auto.rules.tabRules").replace("{n}", String(rules.length))}
</button>
<button
type="button"
onClick={() => setActiveTab("activity")}
className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "activity" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
>
Activity Log ({actLog.length})
{t("auto.rules.tabActivity").replace("{n}", String(actLog.length))}
</button>
</div>
{activeTab === "rules" ? (
<>
{rules.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
{(["all", "active", "paused", "error"] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setFilterStatus(s)}
className={`rounded-full px-3 py-1.5 text-[11px] font-medium transition ${filterStatus === s ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-[color:var(--border)]"}`}
>
{s === "all" ? "All" : s.charAt(0).toUpperCase() + s.slice(1)}
</button>
))}
{(["all", "active", "paused", "error"] as const).map((s) => {
const filterLabel = s === "all" ? t("auto.rules.filterAll")
: s === "active" ? t("auto.rules.filterActive")
: s === "paused" ? t("auto.rules.filterPaused")
: t("auto.rules.filterError");
return (
<button
key={s}
type="button"
onClick={() => setFilterStatus(s)}
className={`rounded-full px-3 py-1.5 text-[11px] font-medium transition ${filterStatus === s ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-[color:var(--border)]"}`}
>
{filterLabel}
</button>
);
})}
</div>
) : null}
{filtered.length === 0 && rules.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
<Lightning size={36} className="mb-3 text-muted-foreground" />
<div className="text-[14px] font-medium text-muted-foreground">No automation rules</div>
<div className="text-[14px] font-medium text-muted-foreground">{t("auto.rules.noRules")}</div>
<div className="mx-auto mt-1 max-w-[42ch] text-[12px] leading-relaxed text-muted-foreground">
Create your first rule to make the company autonomous.
{t("auto.rules.noRulesDetail")}
</div>
<div className="mt-4">
<ActionButton label="Create First Rule" onClick={() => setShowCreate(true)} icon={Lightning} tone="accent" />
<ActionButton label={t("auto.rules.createFirstRule")} onClick={() => setShowCreate(true)} icon={Lightning} tone="accent" />
</div>
</div>
) : filtered.length === 0 ? (
<div className="py-8 text-center text-[13px] text-muted-foreground">No rules match the current filter.</div>
<div className="py-8 text-center text-[13px] text-muted-foreground">{t("auto.rules.noRulesFilter")}</div>
) : (
<div className="space-y-2">
<AnimatePresence>
@@ -159,7 +165,7 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
)}
</>
) : (
<AutomationActivityLog entries={actLog} />
<AutomationActivityLog entries={actLog} t={t} />
)}
<AnimatePresence>
{showCreate ? (
@@ -171,8 +177,8 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
</AnimatePresence>
<ConfirmDialog
open={!!delTarget}
title="Delete rule"
message={delTarget ? `Are you sure you want to delete ${delTarget.name}? This action cannot be undone.` : ""}
title={t("auto.rules.deleteRule")}
message={delTarget ? t("auto.rules.deleteRuleConfirm").replace("{name}", delTarget.name) : ""}
onConfirm={() => void handleDelete()}
onCancel={() => setDelTarget(null)}
/>
@@ -180,11 +186,11 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
)
}
function WorkflowsPanel({ workflows, companyId, onRefresh }: { workflows: WorkflowPipelineRecord[]; companyId: string; onRefresh: () => void }) {
function WorkflowsPanel({ workflows, companyId, onRefresh, t }: { workflows: WorkflowPipelineRecord[]; companyId: string; onRefresh: () => void; t: TranslationFn }) {
const [showCreate, setShowCreate] = useState(false); const [editWf, setEditWf] = useState<WorkflowPipelineRecord | null>(null); const [delTarget, setDelTarget] = useState<WorkflowPipelineRecord | null>(null)
const [actionError, setActionError] = useState<string | null>(null); const [runningId, setRunningId] = useState<string | null>(null)
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteWorkflow({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed"); setDelTarget(null) } }, [delTarget, companyId, onRefresh])
const handleRun = useCallback(async (wf: WorkflowPipelineRecord) => { setActionError(null); setRunningId(wf.id); try { const r = await window.agentCompany.runWorkflow({ id: wf.id, companyId }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed") } finally { setRunningId(null) } }, [companyId, onRefresh])
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteWorkflow({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")); setDelTarget(null) } }, [delTarget, companyId, onRefresh, t])
const handleRun = useCallback(async (wf: WorkflowPipelineRecord) => { setActionError(null); setRunningId(wf.id); try { const r = await window.agentCompany.runWorkflow({ id: wf.id, companyId }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")) } finally { setRunningId(null) } }, [companyId, onRefresh, t])
const counts = useMemo(() => ({ total: workflows.length, active: workflows.filter((w) => w.status === "active").length, draft: workflows.filter((w) => w.status === "draft").length }), [workflows])
return (
<div className="space-y-5">
@@ -202,24 +208,24 @@ function WorkflowsPanel({ workflows, companyId, onRefresh }: { workflows: Workfl
<ArrowsClockwise size={20} weight="fill" />
</div>
<div>
<h2 className="text-[16px] font-bold text-foreground">Workflow Pipelines</h2>
<h2 className="text-[16px] font-bold text-foreground">{t("auto.workflows.title")}</h2>
<p className="text-[12px] text-muted-foreground">
{counts.active} active, {counts.draft} draft
{counts.total > counts.active + counts.draft ? `, ${counts.total - counts.active - counts.draft} other` : ""}
{t("auto.workflows.summaryActive").replace("{n}", String(counts.active))},{t("auto.workflows.summaryDraft").replace("{n}", String(counts.draft))}
{counts.total > counts.active + counts.draft ? `,${t("auto.workflows.summaryOther").replace("{n}", String(counts.total - counts.active - counts.draft))}` : ""}
</p>
</div>
</div>
<ActionButton label="New Workflow" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" />
<ActionButton label={t("auto.workflows.newWorkflow")} onClick={() => setShowCreate(true)} icon={Plus} tone="accent" />
</div>
{workflows.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
<ArrowsClockwise size={36} className="mb-3 text-muted-foreground" />
<div className="text-[14px] font-medium text-muted-foreground">No workflow pipelines</div>
<div className="text-[14px] font-medium text-muted-foreground">{t("auto.workflows.noWorkflows")}</div>
<div className="mx-auto mt-1 max-w-[42ch] text-[12px] leading-relaxed text-muted-foreground">
Workflows chain actions into multi-step pipelines.
{t("auto.workflows.noWorkflowsDetail")}
</div>
<div className="mt-4">
<ActionButton label="Create First Workflow" onClick={() => setShowCreate(true)} icon={ArrowsClockwise} tone="accent" />
<ActionButton label={t("auto.workflows.createFirstWorkflow")} onClick={() => setShowCreate(true)} icon={ArrowsClockwise} tone="accent" />
</div>
</div>
) : (
@@ -248,8 +254,8 @@ function WorkflowsPanel({ workflows, companyId, onRefresh }: { workflows: Workfl
</AnimatePresence>
<ConfirmDialog
open={!!delTarget}
title="Delete workflow"
message={delTarget ? `Are you sure you want to delete ${delTarget.name}? This action cannot be undone.` : ""}
title={t("auto.workflows.deleteWorkflow")}
message={delTarget ? t("auto.workflows.deleteWorkflowConfirm").replace("{name}", delTarget.name) : ""}
onConfirm={() => void handleDelete()}
onCancel={() => setDelTarget(null)}
/>
@@ -269,7 +275,7 @@ export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { ru
<div>
<div className="text-[13px] font-semibold text-foreground">{t("auto.automationEngine")}</div>
<div className="mt-0.5 text-[11px] leading-relaxed text-muted-foreground">
Rules trigger actions based on company events. Workflows chain multiple steps into pipelines. Together, they let your AI company run fully autonomously.
{t("auto.engineDescription")}
</div>
</div>
</div>
@@ -281,7 +287,7 @@ export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { ru
className={`flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-[13px] font-medium transition ${activeSection === "rules" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
>
<Lightning size={15} weight={activeSection === "rules" ? "fill" : "regular"} className={activeSection === "rules" ? "text-primary" : ""} />
Automation Rules ({rules.length})
{t("auto.tabRules").replace("{n}", String(rules.length))}
</button>
<button
type="button"
@@ -289,7 +295,7 @@ export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { ru
className={`flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-[13px] font-medium transition ${activeSection === "workflows" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
>
<ArrowsClockwise size={15} weight={activeSection === "workflows" ? "fill" : "regular"} className={activeSection === "workflows" ? "text-primary" : ""} />
Workflows ({workflows.length})
{t("auto.tabWorkflows").replace("{n}", String(workflows.length))}
</button>
</div>
<AnimatePresence mode="wait">
@@ -301,9 +307,9 @@ export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { ru
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
>
{activeSection === "rules" ? (
<RulesPanel rules={rules} companyId={companyId} onRefresh={onRefresh} />
<RulesPanel rules={rules} companyId={companyId} onRefresh={onRefresh} t={t} />
) : (
<WorkflowsPanel workflows={workflows} companyId={companyId} onRefresh={onRefresh} />
<WorkflowsPanel workflows={workflows} companyId={companyId} onRefresh={onRefresh} t={t} />
)}
</motion.div>
</AnimatePresence>
@@ -1 +0,0 @@
export { CommunicationHub } from "./communication/CommunicationHub"
@@ -5,13 +5,16 @@ import type { AgentRecord, MessageChannel } from "@shared/types"
import { AgentAvatar } from "./MessageItem"
import type { ChannelDescriptor, TaskDiscussionDescriptor } from "./ChannelHeader"
import { useT } from "../../i18n"
import { Eyebrow } from "../ui"
const channelConfig: Record<MessageChannel, { label: string; icon: React.ElementType }> = {
direct: { label: "Direct Messages", icon: EnvelopeSimple },
department: { label: "Department", icon: Buildings },
company: { label: "Company-wide", icon: Megaphone },
project: { label: "Project", icon: Hash },
incident: { label: "Incident", icon: Fire },
type ChannelConfig = { labelKey: string; icon: React.ElementType }
const channelConfig: Record<MessageChannel, ChannelConfig> = {
direct: { labelKey: "comm.channelLabel.direct", icon: EnvelopeSimple },
department: { labelKey: "comm.channelLabel.department", icon: Buildings },
company: { labelKey: "comm.channelLabel.company", icon: Megaphone },
project: { labelKey: "comm.channelLabel.project", icon: Hash },
incident: { labelKey: "comm.channelLabel.incident", icon: Fire },
}
function ChannelItem({
@@ -111,9 +114,7 @@ export function ChannelSidebar({
<div className="flex-1 overflow-y-auto px-2 py-3">
{channelGroups.map((group) => (
<div key={group.type} className="mb-3">
<div className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{group.label}
</div>
<Eyebrow className="mb-1 px-2">{group.label}</Eyebrow>
{group.items.map((ch) => {
const stats = unreadByChannel.get(ch.key)
return (
@@ -133,9 +134,7 @@ export function ChannelSidebar({
{taskDiscussionChannels.length > 0 ? (
<div className="mb-3">
<div className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{t("comm.taskDiscussions")}
</div>
<Eyebrow className="mb-1 px-2">{t("comm.taskDiscussions")}</Eyebrow>
{taskDiscussionChannels.map((discussion) => (
<button
key={discussion.key}
@@ -165,8 +164,8 @@ export function ChannelSidebar({
<div className="border-t border-border px-4 py-2">
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>{totalChannels} channels</span>
<span>{agents.length} agents</span>
<span>{t("comm.channelsTotal").replace("{count}", String(totalChannels))}</span>
<span>{t("comm.agentsTotal").replace("{count}", String(agents.length))}</span>
</div>
</div>
</motion.div>
@@ -11,6 +11,7 @@ import { MessageComposer, TaskDiscussionComposer, NoChannelComposer } from "./Me
import { SearchResults } from "./SearchResults"
import ThreadPanel from "./ThreadPanel"
import { deriveChannels, deriveTaskDiscussionChannels, getChannelMessages } from "./channel-utils"
import { useT, type TranslationFn } from "../../i18n"
interface CommunicationHubProps {
@@ -19,12 +20,12 @@ interface CommunicationHubProps {
onEntityConsumed?: () => void; onNavigateToTask: (taskId: string) => void; onRefresh: () => void
}
function SearchScopeToggle({ scope, onChangeScope }: { scope: "global" | "channel"; onChangeScope: (scope: "global" | "channel") => void }) {
function SearchScopeToggle({ scope, onChangeScope, t }: { scope: "global" | "channel"; onChangeScope: (scope: "global" | "channel") => void; t: TranslationFn }) {
return (
<div
className="inline-flex items-center gap-0.5 rounded-full bg-card p-0.5"
role="radiogroup"
aria-label="Search scope"
aria-label={t("comm.searchScope")}
>
<button
type="button"
@@ -38,7 +39,7 @@ function SearchScopeToggle({ scope, onChangeScope }: { scope: "global" | "channe
: "bg-muted text-muted-foreground"
}`}
>
All Channels
{t("comm.allChannels")}
</button>
<button
type="button"
@@ -52,13 +53,14 @@ function SearchScopeToggle({ scope, onChangeScope }: { scope: "global" | "channe
: "bg-muted text-muted-foreground"
}`}
>
This Channel
{t("comm.thisChannel")}
</button>
</div>
)
}
export function CommunicationHub({ messages, comments, tasks, agents, projects, documents, companyId, selectedEntityId, onEntityConsumed, onNavigateToTask, onRefresh }: CommunicationHubProps) {
const t = useT()
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
const [showSidebar, setShowSidebar] = useState(true)
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
@@ -156,7 +158,10 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
} else {
setSearchResults([], currentRequestId)
}
} catch {
} catch (error) {
console.warn(
`[communication] searchMessages failed: ${error instanceof Error ? error.message : String(error)}`,
)
setSearchResults([], currentRequestId)
}
}, 300)
@@ -203,7 +208,7 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
void (async () => {
for (const id of ids) pendingReadIdsRef.current.add(id)
setOptimisticReadIds((c) => { const n = new Set(c); for (const id of ids) n.add(id); return n })
const results = await Promise.all(ids.map(async (id) => { try { unwrap(await window.agentCompany.markMessageRead({ messageId: id, companyId })); return { id, ok: true as const } } catch { return { id, ok: false as const } } }))
const results = await Promise.all(ids.map(async (id) => { try { unwrap(await window.agentCompany.markMessageRead({ messageId: id, companyId })); return { id, ok: true as const } } catch (error) { console.warn(`[communication] markMessageRead(${id}) failed: ${error instanceof Error ? error.message : String(error)}`); return { id, ok: false as const } } }))
const failed = results.filter((r) => !r.ok).map((r) => r.id)
for (const id of ids) pendingReadIdsRef.current.delete(id)
if (!cancelled && failed.length > 0) setOptimisticReadIds((c) => { const n = new Set(c); for (const id of failed) n.delete(id); return n })
@@ -220,16 +225,22 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
const channelGroups = useMemo(() => {
const groups: Array<{ type: MessageChannel; label: string; items: ChannelDescriptor[] }> = []
const cfgs: Record<MessageChannel, string> = { company: "Company-wide", department: "Department", project: "Project", incident: "Incident", direct: "Direct Messages" }
const cfgs: Record<MessageChannel, string> = {
company: t("comm.channelGroup.company"),
department: t("comm.channelGroup.department"),
project: t("comm.channelGroup.project"),
incident: t("comm.channelGroup.incident"),
direct: t("comm.channelGroup.direct"),
}
for (const type of (["company", "department", "project", "incident", "direct"] as MessageChannel[])) {
const items = channels.filter((c): c is ChannelDescriptor => c.kind === "message" && c.channel === type)
if (items.length > 0) groups.push({ type, label: cfgs[type], items })
}
return groups
}, [channels])
}, [channels, t])
const scopeToggleElement = isSearchActive ? (
<SearchScopeToggle scope={searchScope} onChangeScope={setSearchScope} />
<SearchScopeToggle scope={searchScope} onChangeScope={setSearchScope} t={t} />
) : null
return (
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff. Show more