bopodev-api 0.1.19 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev-api",
3
- "version": "0.1.19",
3
+ "version": "0.1.22",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "files": [
@@ -17,9 +17,9 @@
17
17
  "nanoid": "^5.1.5",
18
18
  "ws": "^8.19.0",
19
19
  "zod": "^4.1.5",
20
- "bopodev-db": "0.1.19",
21
- "bopodev-agent-sdk": "0.1.19",
22
- "bopodev-contracts": "0.1.19"
20
+ "bopodev-agent-sdk": "0.1.22",
21
+ "bopodev-db": "0.1.22",
22
+ "bopodev-contracts": "0.1.22"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/cors": "^2.8.19",
@@ -0,0 +1,12 @@
1
+ export function buildDefaultCeoBootstrapPrompt() {
2
+ return [
3
+ "You are the CEO agent responsible for organization design and hiring quality.",
4
+ "When a delegated request asks you to create an agent:",
5
+ "- Clarify missing constraints before hiring when requirements are ambiguous.",
6
+ "- Choose reporting lines, provider, model, and permissions that fit company goals and budget.",
7
+ "- Use governance-safe hiring via `POST /agents` with `requestApproval: true` unless explicitly told otherwise.",
8
+ "- Avoid duplicate hires by checking existing agents and pending approvals first.",
9
+ "- Use the control-plane coordination skill as the source of truth for endpoint paths, required headers, and approval workflow steps.",
10
+ "- Record hiring rationale and key decisions in issue comments for auditability."
11
+ ].join("\n");
12
+ }
@@ -0,0 +1,60 @@
1
+ type AgentLike = {
2
+ id: string;
3
+ name: string;
4
+ role: string;
5
+ status: string;
6
+ canHireAgents?: boolean | null;
7
+ };
8
+
9
+ export type HiringDelegateResolution =
10
+ | {
11
+ delegate: {
12
+ agentId: string;
13
+ name: string;
14
+ role: string;
15
+ };
16
+ reason: "ceo_with_hiring_capability" | "first_hiring_capable_agent";
17
+ }
18
+ | {
19
+ delegate: null;
20
+ reason: "no_hiring_capable_agent";
21
+ };
22
+
23
+ export function resolveHiringDelegate(agents: AgentLike[]): HiringDelegateResolution {
24
+ const eligible = agents
25
+ .filter((agent) => agent.status !== "terminated")
26
+ .filter((agent) => Boolean(agent.canHireAgents));
27
+ if (eligible.length === 0) {
28
+ return {
29
+ delegate: null,
30
+ reason: "no_hiring_capable_agent"
31
+ };
32
+ }
33
+ const normalized = eligible.map((agent) => ({
34
+ ...agent,
35
+ normalizedRole: agent.role.trim().toLowerCase(),
36
+ normalizedName: agent.name.trim().toLowerCase()
37
+ }));
38
+ const ceo =
39
+ normalized.find((agent) => agent.normalizedRole === "ceo") ??
40
+ normalized.find((agent) => agent.normalizedName === "ceo");
41
+ if (ceo) {
42
+ return {
43
+ delegate: {
44
+ agentId: ceo.id,
45
+ name: ceo.name,
46
+ role: ceo.role
47
+ },
48
+ reason: "ceo_with_hiring_capability"
49
+ };
50
+ }
51
+ const fallback = [...normalized].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0]!;
52
+ return {
53
+ delegate: {
54
+ agentId: fallback.id,
55
+ name: fallback.name,
56
+ role: fallback.role
57
+ },
58
+ reason: "first_hiring_capable_agent"
59
+ };
60
+ }
@@ -27,6 +27,7 @@ import {
27
27
  runtimeConfigToDb,
28
28
  runtimeConfigToStateBlobPatch
29
29
  } from "../lib/agent-config";
30
+ import { resolveHiringDelegate } from "../lib/hiring-delegate";
30
31
  import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
31
32
  import { assertRuntimeCwdForCompany, hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
32
33
  import { requireCompanyScope } from "../middleware/company-scope";
@@ -147,6 +148,53 @@ export function createAgentsRouter(ctx: AppContext) {
147
148
  );
148
149
  });
149
150
 
151
+ router.get("/hiring-delegate", async (req, res) => {
152
+ const rows = await listAgents(ctx.db, req.companyId!);
153
+ const resolution = resolveHiringDelegate(
154
+ rows.map((row) => ({
155
+ id: row.id,
156
+ name: row.name,
157
+ role: row.role,
158
+ status: row.status,
159
+ canHireAgents: row.canHireAgents
160
+ }))
161
+ );
162
+ return sendOk(res, {
163
+ delegate: resolution.delegate,
164
+ reason: resolution.reason
165
+ });
166
+ });
167
+
168
+ router.get("/leadership-diagnostics", async (req, res) => {
169
+ const rows = await listAgents(ctx.db, req.companyId!);
170
+ return sendOk(
171
+ res,
172
+ rows.map((row) => {
173
+ const normalizedRole = row.role.trim().toLowerCase();
174
+ const isLeadership = normalizedRole === "ceo" || Boolean(row.canHireAgents);
175
+ const issues: string[] = [];
176
+ const hasBootstrapPrompt = hasText(row.bootstrapPrompt);
177
+ if (isLeadership && !hasBootstrapPrompt) {
178
+ issues.push("missing_bootstrap_prompt");
179
+ }
180
+ if (isLeadership && !providerSupportsSkillInjection(row.providerType)) {
181
+ issues.push("provider_without_runtime_skill_injection");
182
+ }
183
+ return {
184
+ agentId: row.id,
185
+ name: row.name,
186
+ role: row.role,
187
+ providerType: row.providerType,
188
+ canHireAgents: Boolean(row.canHireAgents),
189
+ isLeadership,
190
+ hasBootstrapPrompt,
191
+ supportsSkillInjection: providerSupportsSkillInjection(row.providerType),
192
+ issues
193
+ };
194
+ })
195
+ );
196
+ });
197
+
150
198
  router.get("/runtime-default-cwd", async (req, res) => {
151
199
  let runtimeCwd: string;
152
200
  try {
@@ -314,7 +362,9 @@ export function createAgentsRouter(ctx: AppContext) {
314
362
  await mkdir(runtimeConfig.runtimeCwd!, { recursive: true });
315
363
  }
316
364
 
317
- if (parsed.data.requestApproval && isApprovalRequired("hire_agent")) {
365
+ const sourceIssueIds = normalizeSourceIssueIds(parsed.data.sourceIssueId, parsed.data.sourceIssueIds);
366
+ const shouldRequestApproval = (parsed.data.requestApproval || req.actor?.type === "agent") && isApprovalRequired("hire_agent");
367
+ if (shouldRequestApproval) {
318
368
  const duplicate = await findDuplicateHireRequest(ctx.db, req.companyId!, {
319
369
  role: parsed.data.role,
320
370
  managerAgentId: parsed.data.managerAgentId ?? null
@@ -330,10 +380,12 @@ export function createAgentsRouter(ctx: AppContext) {
330
380
  }
331
381
  const approvalId = await createApprovalRequest(ctx.db, {
332
382
  companyId: req.companyId!,
383
+ requestedByAgentId: req.actor?.type === "agent" ? req.actor.id : null,
333
384
  action: "hire_agent",
334
385
  payload: {
335
386
  ...parsed.data,
336
- runtimeConfig
387
+ runtimeConfig,
388
+ sourceIssueIds
337
389
  }
338
390
  });
339
391
  const approval = await getApprovalRequest(ctx.db, req.companyId!, approvalId);
@@ -361,14 +413,18 @@ export function createAgentsRouter(ctx: AppContext) {
361
413
  ...runtimeConfigToDb(runtimeConfig),
362
414
  initialState: runtimeConfigToStateBlobPatch(runtimeConfig)
363
415
  });
364
-
416
+ const auditActor = resolveAuditActor(req.actor);
365
417
  await appendAuditEvent(ctx.db, {
366
418
  companyId: req.companyId!,
367
- actorType: "human",
419
+ actorType: auditActor.actorType,
420
+ actorId: auditActor.actorId,
368
421
  eventType: "agent.hired",
369
422
  entityType: "agent",
370
423
  entityId: agent.id,
371
- payload: agent
424
+ payload: {
425
+ ...agent,
426
+ sourceIssueIds
427
+ }
372
428
  });
373
429
  await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, agent.id);
374
430
  return sendOk(res, toAgentResponse(agent as unknown as Record<string, unknown>));
@@ -398,6 +454,34 @@ export function createAgentsRouter(ctx: AppContext) {
398
454
  if (!existingAgent) {
399
455
  return sendError(res, "Agent not found.", 404);
400
456
  }
457
+ if (req.actor?.type === "agent") {
458
+ if (req.actor.id !== req.params.agentId) {
459
+ return sendError(res, "Agents can only update their own record.", 403);
460
+ }
461
+ const forbiddenFieldUpdates: string[] = [];
462
+ if (parsed.data.canHireAgents !== undefined) {
463
+ forbiddenFieldUpdates.push("canHireAgents");
464
+ }
465
+ if (parsed.data.status !== undefined) {
466
+ forbiddenFieldUpdates.push("status");
467
+ }
468
+ if (parsed.data.monthlyBudgetUsd !== undefined) {
469
+ forbiddenFieldUpdates.push("monthlyBudgetUsd");
470
+ }
471
+ if (parsed.data.providerType !== undefined) {
472
+ forbiddenFieldUpdates.push("providerType");
473
+ }
474
+ if (parsed.data.managerAgentId !== undefined) {
475
+ forbiddenFieldUpdates.push("managerAgentId");
476
+ }
477
+ if (forbiddenFieldUpdates.length > 0) {
478
+ return sendError(
479
+ res,
480
+ `Agents cannot update restricted fields: ${forbiddenFieldUpdates.join(", ")}.`,
481
+ 403
482
+ );
483
+ }
484
+ }
401
485
  const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(ctx.db, req.companyId!);
402
486
  const existingRuntime = parseRuntimeConfigFromAgentRow(existingAgent as unknown as Record<string, unknown>);
403
487
  const effectiveProviderType = parsed.data.providerType ?? existingAgent.providerType;
@@ -474,9 +558,11 @@ export function createAgentsRouter(ctx: AppContext) {
474
558
  return sendError(res, "Agent not found.", 404);
475
559
  }
476
560
 
561
+ const auditActor = resolveAuditActor(req.actor);
477
562
  await appendAuditEvent(ctx.db, {
478
563
  companyId: req.companyId!,
479
- actorType: "human",
564
+ actorType: auditActor.actorType,
565
+ actorId: auditActor.actorId,
480
566
  eventType: "agent.updated",
481
567
  entityType: "agent",
482
568
  entityId: agent.id,
@@ -499,9 +585,11 @@ export function createAgentsRouter(ctx: AppContext) {
499
585
  return sendError(res, "Agent not found.", 404);
500
586
  }
501
587
 
588
+ const auditActor = resolveAuditActor(req.actor);
502
589
  await appendAuditEvent(ctx.db, {
503
590
  companyId: req.companyId!,
504
- actorType: "human",
591
+ actorType: auditActor.actorType,
592
+ actorId: auditActor.actorId,
505
593
  eventType: "agent.deleted",
506
594
  entityType: "agent",
507
595
  entityId: req.params.agentId,
@@ -524,9 +612,11 @@ export function createAgentsRouter(ctx: AppContext) {
524
612
  if (!agent) {
525
613
  return sendError(res, "Agent not found.", 404);
526
614
  }
615
+ const auditActor = resolveAuditActor(req.actor);
527
616
  await appendAuditEvent(ctx.db, {
528
617
  companyId: req.companyId!,
529
- actorType: "human",
618
+ actorType: auditActor.actorType,
619
+ actorId: auditActor.actorId,
530
620
  eventType: "agent.paused",
531
621
  entityType: "agent",
532
622
  entityId: agent.id,
@@ -549,9 +639,11 @@ export function createAgentsRouter(ctx: AppContext) {
549
639
  if (!agent) {
550
640
  return sendError(res, "Agent not found.", 404);
551
641
  }
642
+ const auditActor = resolveAuditActor(req.actor);
552
643
  await appendAuditEvent(ctx.db, {
553
644
  companyId: req.companyId!,
554
- actorType: "human",
645
+ actorType: auditActor.actorType,
646
+ actorId: auditActor.actorId,
555
647
  eventType: "agent.resumed",
556
648
  entityType: "agent",
557
649
  entityId: agent.id,
@@ -574,9 +666,11 @@ export function createAgentsRouter(ctx: AppContext) {
574
666
  if (!agent) {
575
667
  return sendError(res, "Agent not found.", 404);
576
668
  }
669
+ const auditActor = resolveAuditActor(req.actor);
577
670
  await appendAuditEvent(ctx.db, {
578
671
  companyId: req.companyId!,
579
- actorType: "human",
672
+ actorType: auditActor.actorType,
673
+ actorId: auditActor.actorId,
580
674
  eventType: "agent.terminated",
581
675
  entityType: "agent",
582
676
  entityId: agent.id,
@@ -670,3 +764,28 @@ function duplicateMessage(input: { existingAgentId: string | null; pendingApprov
670
764
  }
671
765
  return `Duplicate hire request blocked: pending approval ${input.pendingApprovalId}.`;
672
766
  }
767
+
768
+ function normalizeSourceIssueIds(sourceIssueId: string | undefined, sourceIssueIds: string[] | undefined) {
769
+ const merged = new Set<string>();
770
+ for (const value of [sourceIssueId, ...(sourceIssueIds ?? [])]) {
771
+ const normalized = value?.trim();
772
+ if (normalized) {
773
+ merged.add(normalized);
774
+ }
775
+ }
776
+ return Array.from(merged);
777
+ }
778
+
779
+ function providerSupportsSkillInjection(providerType: string) {
780
+ return providerType === "codex" || providerType === "cursor" || providerType === "opencode" || providerType === "claude_code";
781
+ }
782
+
783
+ function resolveAuditActor(actor: { type: "board" | "member" | "agent"; id: string } | undefined) {
784
+ if (!actor) {
785
+ return { actorType: "human" as const, actorId: null as string | null };
786
+ }
787
+ if (actor.type === "agent") {
788
+ return { actorType: "agent" as const, actorId: actor.id };
789
+ }
790
+ return { actorType: "human" as const, actorId: actor.id };
791
+ }
@@ -1,15 +1,29 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import type { NextFunction, Request, Response } from "express";
1
3
  import { Router } from "express";
2
4
  import { z } from "zod";
3
- import { createCompany, deleteCompany, listCompanies, updateCompany } from "bopodev-db";
5
+ import { createAgent, createCompany, deleteCompany, listCompanies, updateCompany } from "bopodev-db";
4
6
  import type { AppContext } from "../context";
5
7
  import { sendError, sendOk } from "../http";
8
+ import { normalizeRuntimeConfig, resolveRuntimeModelForProvider, runtimeConfigToDb, runtimeConfigToStateBlobPatch } from "../lib/agent-config";
9
+ import { buildDefaultCeoBootstrapPrompt } from "../lib/ceo-bootstrap-prompt";
10
+ import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
11
+ import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
12
+ import { canAccessCompany, requireBoardRole, requirePermission } from "../middleware/request-actor";
6
13
  import { ensureCompanyModelPricingDefaults } from "../services/model-pricing";
7
14
  import { ensureCompanyBuiltinPluginDefaults } from "../services/plugin-runtime";
8
15
  import { ensureCompanyBuiltinTemplateDefaults } from "../services/template-catalog";
9
16
 
17
+ const DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
18
+ const DEFAULT_AGENT_MODEL_ENV = "BOPO_DEFAULT_AGENT_MODEL";
19
+
10
20
  const createCompanySchema = z.object({
11
21
  name: z.string().min(1),
12
- mission: z.string().optional()
22
+ mission: z.string().optional(),
23
+ providerType: z
24
+ .enum(["codex", "claude_code", "cursor", "gemini_cli", "opencode", "openai_api", "anthropic_api", "shell"])
25
+ .optional(),
26
+ runtimeModel: z.string().optional()
13
27
  });
14
28
 
15
29
  const updateCompanySchema = z
@@ -22,38 +36,98 @@ const updateCompanySchema = z
22
36
  export function createCompaniesRouter(ctx: AppContext) {
23
37
  const router = Router();
24
38
 
25
- router.get("/", async (_req, res) => {
39
+ router.get("/", async (req, res) => {
26
40
  const companies = await listCompanies(ctx.db);
27
- return sendOk(res, companies);
41
+ if (req.actor?.type === "board") {
42
+ return sendOk(res, companies);
43
+ }
44
+ const visibleCompanyIds = new Set(req.actor?.companyIds ?? []);
45
+ return sendOk(
46
+ res,
47
+ companies.filter((company) => visibleCompanyIds.has(company.id))
48
+ );
28
49
  });
29
50
 
30
- router.post("/", async (req, res) => {
51
+ router.post("/", requireBoardRole, async (req, res) => {
31
52
  const parsed = createCompanySchema.safeParse(req.body);
32
53
  if (!parsed.success) {
33
54
  return sendError(res, parsed.error.message, 422);
34
55
  }
35
56
  const company = await createCompany(ctx.db, parsed.data);
57
+ const providerType =
58
+ parseAgentProvider(parsed.data.providerType) ??
59
+ parseAgentProvider(process.env[DEFAULT_AGENT_PROVIDER_ENV]) ??
60
+ "shell";
61
+ const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(ctx.db, company.id);
62
+ await mkdir(defaultRuntimeCwd, { recursive: true });
63
+ const requestedModel = parsed.data.runtimeModel?.trim() || process.env[DEFAULT_AGENT_MODEL_ENV]?.trim() || undefined;
64
+ const resolvedRuntimeModel = resolveRuntimeModelForProvider(
65
+ providerType,
66
+ await resolveOpencodeRuntimeModel(
67
+ providerType,
68
+ normalizeRuntimeConfig({
69
+ defaultRuntimeCwd,
70
+ runtimeConfig: {
71
+ runtimeModel: requestedModel,
72
+ runtimeEnv: resolveSeedRuntimeEnv(providerType)
73
+ }
74
+ })
75
+ )
76
+ );
77
+ const defaultRuntimeConfig = normalizeRuntimeConfig({
78
+ defaultRuntimeCwd,
79
+ runtimeConfig: {
80
+ runtimeModel: resolvedRuntimeModel,
81
+ bootstrapPrompt: buildDefaultCeoBootstrapPrompt(),
82
+ runtimeEnv: resolveSeedRuntimeEnv(providerType),
83
+ ...(providerType === "shell"
84
+ ? {
85
+ runtimeCommand: "echo",
86
+ runtimeArgs: ["ceo bootstrap heartbeat"]
87
+ }
88
+ : {})
89
+ }
90
+ });
91
+ await createAgent(ctx.db, {
92
+ companyId: company.id,
93
+ role: "CEO",
94
+ name: "CEO",
95
+ providerType,
96
+ heartbeatCron: "*/5 * * * *",
97
+ monthlyBudgetUsd: "100.0000",
98
+ canHireAgents: true,
99
+ ...runtimeConfigToDb(defaultRuntimeConfig),
100
+ initialState: runtimeConfigToStateBlobPatch(defaultRuntimeConfig)
101
+ });
36
102
  await ensureCompanyBuiltinPluginDefaults(ctx.db, company.id);
37
103
  await ensureCompanyBuiltinTemplateDefaults(ctx.db, company.id);
38
104
  await ensureCompanyModelPricingDefaults(ctx.db, company.id);
39
105
  return sendOk(res, company);
40
106
  });
41
107
 
42
- router.put("/:companyId", async (req, res) => {
108
+ router.put("/:companyId", requireCompanyWriteAccess, async (req, res) => {
43
109
  const parsed = updateCompanySchema.safeParse(req.body);
44
110
  if (!parsed.success) {
45
111
  return sendError(res, parsed.error.message, 422);
46
112
  }
47
113
 
48
- const company = await updateCompany(ctx.db, { id: req.params.companyId, ...parsed.data });
114
+ const companyId = readCompanyIdParam(req);
115
+ if (!companyId) {
116
+ return sendError(res, "Missing company id.", 422);
117
+ }
118
+ const company = await updateCompany(ctx.db, { id: companyId, ...parsed.data });
49
119
  if (!company) {
50
120
  return sendError(res, "Company not found.", 404);
51
121
  }
52
122
  return sendOk(res, company);
53
123
  });
54
124
 
55
- router.delete("/:companyId", async (req, res) => {
56
- const deleted = await deleteCompany(ctx.db, req.params.companyId);
125
+ router.delete("/:companyId", requireCompanyWriteAccess, async (req, res) => {
126
+ const companyId = readCompanyIdParam(req);
127
+ if (!companyId) {
128
+ return sendError(res, "Missing company id.", 422);
129
+ }
130
+ const deleted = await deleteCompany(ctx.db, companyId);
57
131
  if (!deleted) {
58
132
  return sendError(res, "Company not found.", 404);
59
133
  }
@@ -62,3 +136,50 @@ export function createCompaniesRouter(ctx: AppContext) {
62
136
 
63
137
  return router;
64
138
  }
139
+
140
+ function requireCompanyWriteAccess(req: Request, res: Response, next: NextFunction) {
141
+ const targetCompanyId = readCompanyIdParam(req);
142
+ if (!targetCompanyId) {
143
+ return sendError(res, "Missing company id.", 422);
144
+ }
145
+ if (!canAccessCompany(req, targetCompanyId)) {
146
+ return sendError(res, "Actor does not have access to this company.", 403);
147
+ }
148
+ if (req.actor?.type === "board") {
149
+ next();
150
+ return;
151
+ }
152
+ return requirePermission("companies:write")(req, res, next);
153
+ }
154
+
155
+ function readCompanyIdParam(req: Request) {
156
+ return typeof req.params.companyId === "string" ? req.params.companyId : null;
157
+ }
158
+
159
+ function parseAgentProvider(value: unknown) {
160
+ if (
161
+ value === "codex" ||
162
+ value === "claude_code" ||
163
+ value === "cursor" ||
164
+ value === "gemini_cli" ||
165
+ value === "opencode" ||
166
+ value === "openai_api" ||
167
+ value === "anthropic_api" ||
168
+ value === "shell"
169
+ ) {
170
+ return value;
171
+ }
172
+ return null;
173
+ }
174
+
175
+ function resolveSeedRuntimeEnv(providerType: string): Record<string, string> {
176
+ if (providerType === "codex" || providerType === "openai_api") {
177
+ const key = (process.env.BOPO_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY)?.trim();
178
+ return key ? { OPENAI_API_KEY: key } : {};
179
+ }
180
+ if (providerType === "claude_code" || providerType === "anthropic_api") {
181
+ const key = (process.env.BOPO_ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_API_KEY)?.trim();
182
+ return key ? { ANTHROPIC_API_KEY: key } : {};
183
+ }
184
+ return {};
185
+ }
@@ -159,9 +159,11 @@ export function createGovernanceRouter(ctx: AppContext) {
159
159
  throw error;
160
160
  }
161
161
 
162
+ const auditActor = resolveAuditActor(req.actor);
162
163
  await appendAuditEvent(ctx.db, {
163
164
  companyId: req.companyId!,
164
- actorType: "human",
165
+ actorType: auditActor.actorType,
166
+ actorId: auditActor.actorId,
165
167
  eventType: "governance.approval_resolved",
166
168
  entityType: "approval_request",
167
169
  entityId: parsed.data.approvalId,
@@ -181,7 +183,8 @@ export function createGovernanceRouter(ctx: AppContext) {
181
183
  : "memory.promoted_from_approval";
182
184
  await appendAuditEvent(ctx.db, {
183
185
  companyId: req.companyId!,
184
- actorType: "human",
186
+ actorType: auditActor.actorType,
187
+ actorId: auditActor.actorId,
185
188
  eventType,
186
189
  entityType: resolution.execution.entityType,
187
190
  entityId: resolution.execution.entityId,
@@ -213,6 +216,16 @@ export function createGovernanceRouter(ctx: AppContext) {
213
216
  return router;
214
217
  }
215
218
 
219
+ function resolveAuditActor(actor: { type: "board" | "member" | "agent"; id: string } | undefined) {
220
+ if (!actor) {
221
+ return { actorType: "human" as const, actorId: null as string | null };
222
+ }
223
+ if (actor.type === "agent") {
224
+ return { actorType: "agent" as const, actorId: actor.id };
225
+ }
226
+ return { actorType: "human" as const, actorId: actor.id };
227
+ }
228
+
216
229
  function parsePayload(payloadJson: string) {
217
230
  try {
218
231
  const parsed = JSON.parse(payloadJson) as unknown;
@@ -37,6 +37,20 @@ const createIssueSchema = z.object({
37
37
  parentIssueId: z.string().optional(),
38
38
  title: z.string().min(1),
39
39
  body: z.string().optional(),
40
+ metadata: z
41
+ .object({
42
+ delegatedHiringIntent: z
43
+ .object({
44
+ intentType: z.literal("agent_hiring_request"),
45
+ requestedRole: z.string().nullable().optional(),
46
+ requestedName: z.string().nullable().optional(),
47
+ requestedManagerAgentId: z.string().nullable().optional(),
48
+ requestedProviderType: z.string().nullable().optional(),
49
+ requestedRuntimeModel: z.string().nullable().optional()
50
+ })
51
+ .optional()
52
+ })
53
+ .optional(),
40
54
  status: z.enum(["todo", "in_progress", "blocked", "in_review", "done", "canceled"]).default("todo"),
41
55
  priority: z.enum(["none", "low", "medium", "high", "urgent"]).default("none"),
42
56
  assigneeAgentId: z.string().nullable().optional(),
@@ -165,7 +179,18 @@ export function createIssuesRouter(ctx: AppContext) {
165
179
  return sendError(res, assignmentValidation, 422);
166
180
  }
167
181
  }
168
- const issue = await createIssue(ctx.db, { companyId: req.companyId!, ...parsed.data });
182
+ const issue = await createIssue(ctx.db, {
183
+ companyId: req.companyId!,
184
+ projectId: parsed.data.projectId,
185
+ parentIssueId: parsed.data.parentIssueId,
186
+ title: parsed.data.title,
187
+ body: applyIssueMetadataToBody(parsed.data.body, parsed.data.metadata),
188
+ status: parsed.data.status,
189
+ priority: parsed.data.priority,
190
+ assigneeAgentId: parsed.data.assigneeAgentId,
191
+ labels: parsed.data.labels,
192
+ tags: parsed.data.tags
193
+ });
169
194
  await appendActivity(ctx.db, {
170
195
  companyId: req.companyId!,
171
196
  issueId: issue.id,
@@ -618,6 +643,34 @@ export function createIssuesRouter(ctx: AppContext) {
618
643
  return router;
619
644
  }
620
645
 
646
+ function applyIssueMetadataToBody(
647
+ body: string | undefined,
648
+ metadata:
649
+ | {
650
+ delegatedHiringIntent?: {
651
+ intentType: "agent_hiring_request";
652
+ requestedRole?: string | null;
653
+ requestedName?: string | null;
654
+ requestedManagerAgentId?: string | null;
655
+ requestedProviderType?: string | null;
656
+ requestedRuntimeModel?: string | null;
657
+ };
658
+ }
659
+ | undefined
660
+ ) {
661
+ if (!metadata || Object.keys(metadata).length === 0) {
662
+ return body;
663
+ }
664
+ const metadataBlock = [
665
+ "",
666
+ "---",
667
+ "<!-- bopodev:issue-metadata:v1",
668
+ JSON.stringify(metadata),
669
+ "-->"
670
+ ].join("\n");
671
+ return `${body ?? ""}${metadataBlock}`.trim();
672
+ }
673
+
621
674
  function parsePayload(payloadJson: string) {
622
675
  try {
623
676
  const parsed = JSON.parse(payloadJson) as unknown;
@@ -14,6 +14,7 @@ import {
14
14
  import type { AppContext } from "../context";
15
15
  import { sendError, sendOk } from "../http";
16
16
  import { requireCompanyScope } from "../middleware/company-scope";
17
+ import { requirePermission } from "../middleware/request-actor";
17
18
  import { listAgentMemoryFiles, readAgentMemoryFile } from "../services/memory-file-service";
18
19
 
19
20
  export function createObservabilityRouter(ctx: AppContext) {
@@ -70,6 +71,10 @@ export function createObservabilityRouter(ctx: AppContext) {
70
71
  });
71
72
 
72
73
  router.put("/models/pricing", async (req, res) => {
74
+ requirePermission("observability:write")(req, res, () => {});
75
+ if (res.headersSent) {
76
+ return;
77
+ }
73
78
  const parsed = modelPricingUpdateSchema.safeParse(req.body);
74
79
  if (!parsed.success) {
75
80
  return sendError(res, parsed.error.message, 422);
@@ -14,6 +14,7 @@ import {
14
14
  import type { AppContext } from "../context";
15
15
  import { sendError, sendOk } from "../http";
16
16
  import { requireCompanyScope } from "../middleware/company-scope";
17
+ import { requireBoardRole, requirePermission } from "../middleware/request-actor";
17
18
  import { deletePluginManifestFromFilesystem, writePluginManifestToFilesystem } from "../services/plugin-manifest-loader";
18
19
  import { registerPluginManifest } from "../services/plugin-runtime";
19
20
 
@@ -73,11 +74,18 @@ export function createPluginsRouter(ctx: AppContext) {
73
74
  });
74
75
 
75
76
  router.put("/:pluginId", async (req, res) => {
77
+ requirePermission("plugins:write")(req, res, () => {});
78
+ if (res.headersSent) {
79
+ return;
80
+ }
76
81
  const parsed = pluginConfigSchema.safeParse(req.body);
77
82
  if (!parsed.success) {
78
83
  return sendError(res, parsed.error.message, 422);
79
84
  }
80
- const pluginId = req.params.pluginId;
85
+ const pluginId = readPluginIdParam(req.params.pluginId);
86
+ if (!pluginId) {
87
+ return sendError(res, "Missing plugin id.", 422);
88
+ }
81
89
  const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
82
90
  const pluginExists = catalog.some((plugin) => plugin.id === pluginId);
83
91
  if (!pluginExists) {
@@ -115,6 +123,10 @@ export function createPluginsRouter(ctx: AppContext) {
115
123
  });
116
124
 
117
125
  router.post("/install-from-json", async (req, res) => {
126
+ requirePermission("plugins:write")(req, res, () => {});
127
+ if (res.headersSent) {
128
+ return;
129
+ }
118
130
  const parsed = pluginManifestCreateSchema.safeParse(req.body);
119
131
  if (!parsed.success) {
120
132
  return sendError(res, parsed.error.message, 422);
@@ -152,7 +164,14 @@ export function createPluginsRouter(ctx: AppContext) {
152
164
  });
153
165
 
154
166
  router.post("/:pluginId/install", async (req, res) => {
155
- const pluginId = req.params.pluginId;
167
+ requirePermission("plugins:write")(req, res, () => {});
168
+ if (res.headersSent) {
169
+ return;
170
+ }
171
+ const pluginId = readPluginIdParam(req.params.pluginId);
172
+ if (!pluginId) {
173
+ return sendError(res, "Missing plugin id.", 422);
174
+ }
156
175
  const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
157
176
  const plugin = catalog.find((item) => item.id === pluginId);
158
177
  if (!plugin) {
@@ -174,7 +193,14 @@ export function createPluginsRouter(ctx: AppContext) {
174
193
  });
175
194
 
176
195
  router.delete("/:pluginId/install", async (req, res) => {
177
- const pluginId = req.params.pluginId;
196
+ requirePermission("plugins:write")(req, res, () => {});
197
+ if (res.headersSent) {
198
+ return;
199
+ }
200
+ const pluginId = readPluginIdParam(req.params.pluginId);
201
+ if (!pluginId) {
202
+ return sendError(res, "Missing plugin id.", 422);
203
+ }
178
204
  const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
179
205
  const plugin = catalog.find((item) => item.id === pluginId);
180
206
  if (!plugin) {
@@ -191,8 +217,11 @@ export function createPluginsRouter(ctx: AppContext) {
191
217
  return sendOk(res, { ok: true, pluginId, installed: false });
192
218
  });
193
219
 
194
- router.delete("/:pluginId", async (req, res) => {
195
- const pluginId = req.params.pluginId;
220
+ router.delete("/:pluginId", requireBoardRole, async (req, res) => {
221
+ const pluginId = readPluginIdParam(req.params.pluginId);
222
+ if (!pluginId) {
223
+ return sendError(res, "Missing plugin id.", 422);
224
+ }
196
225
  const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
197
226
  const plugin = catalog.find((item) => item.id === pluginId);
198
227
  if (!plugin) {
@@ -232,6 +261,10 @@ export function createPluginsRouter(ctx: AppContext) {
232
261
  return router;
233
262
  }
234
263
 
264
+ function readPluginIdParam(value: string | string[] | undefined) {
265
+ return typeof value === "string" ? value : null;
266
+ }
267
+
235
268
  function safeParseStringArray(value: string | null | undefined) {
236
269
  if (!value) {
237
270
  return [] as string[];
@@ -29,6 +29,7 @@ import {
29
29
  resolveAgentFallbackWorkspacePath,
30
30
  resolveProjectWorkspacePath
31
31
  } from "../lib/instance-paths";
32
+ import { buildDefaultCeoBootstrapPrompt } from "../lib/ceo-bootstrap-prompt";
32
33
  import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
33
34
  import { ensureCompanyModelPricingDefaults } from "../services/model-pricing";
34
35
  import { applyTemplateManifest } from "../services/template-apply-service";
@@ -122,6 +123,10 @@ export async function ensureOnboardingSeed(input: {
122
123
  });
123
124
  let ceoId = existingCeo?.id ?? null;
124
125
  if (!existingCeo) {
126
+ const ceoCreateRuntimeConfig = {
127
+ ...defaultRuntimeConfig,
128
+ bootstrapPrompt: buildDefaultCeoBootstrapPrompt()
129
+ };
125
130
  const ceo = await createAgent(db, {
126
131
  companyId,
127
132
  role: "CEO",
@@ -130,13 +135,13 @@ export async function ensureOnboardingSeed(input: {
130
135
  heartbeatCron: "*/5 * * * *",
131
136
  monthlyBudgetUsd: "100.0000",
132
137
  canHireAgents: true,
133
- ...runtimeConfigToDb(defaultRuntimeConfig),
134
- initialState: runtimeConfigToStateBlobPatch(defaultRuntimeConfig)
138
+ ...runtimeConfigToDb(ceoCreateRuntimeConfig),
139
+ initialState: runtimeConfigToStateBlobPatch(ceoCreateRuntimeConfig)
135
140
  });
136
141
  ceoId = ceo.id;
137
142
  ceoCreated = true;
138
143
  ceoProviderType = agentProvider;
139
- ceoRuntimeModel = ceo.runtimeModel ?? defaultRuntimeConfig.runtimeModel ?? null;
144
+ ceoRuntimeModel = ceo.runtimeModel ?? ceoCreateRuntimeConfig.runtimeModel ?? null;
140
145
  } else if (isBootstrapCeoRuntime(existingCeo.providerType, existingCeo.stateBlob)) {
141
146
  const nextState = {
142
147
  ...stripRuntimeFromState(existingCeo.stateBlob),
@@ -50,6 +50,21 @@ const approvalGatedActions = new Set([
50
50
  ]);
51
51
 
52
52
  const hireAgentPayloadSchema = AgentCreateRequestSchema.extend({
53
+ sourceIssueId: z.string().min(1).optional(),
54
+ sourceIssueIds: z.array(z.string().min(1)).default([]),
55
+ delegationIntent: z
56
+ .object({
57
+ intentType: z.literal("agent_hiring_request"),
58
+ requestedRole: z.string().nullable().optional(),
59
+ requestedName: z.string().nullable().optional(),
60
+ requestedManagerAgentId: z.string().nullable().optional(),
61
+ requestedProviderType: z
62
+ .enum(["claude_code", "codex", "cursor", "opencode", "gemini_cli", "openai_api", "anthropic_api", "http", "shell"])
63
+ .nullable()
64
+ .optional(),
65
+ requestedRuntimeModel: z.string().nullable().optional()
66
+ })
67
+ .optional(),
53
68
  runtimeCommand: z.string().optional(),
54
69
  runtimeArgs: z.array(z.string()).optional(),
55
70
  runtimeCwd: z.string().optional(),
@@ -194,6 +209,13 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
194
209
  if (!parsed.success) {
195
210
  throw new GovernanceError("Approval payload for agent hiring is invalid.");
196
211
  }
212
+ const sourceIssueIds = Array.from(
213
+ new Set(
214
+ [parsed.data.sourceIssueId, ...(parsed.data.sourceIssueIds ?? [])]
215
+ .map((entry) => entry?.trim())
216
+ .filter((entry): entry is string => Boolean(entry))
217
+ )
218
+ );
197
219
  const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(db, companyId);
198
220
  const runtimeConfig = normalizeRuntimeConfig({
199
221
  runtimeConfig: parsed.data.runtimeConfig,
@@ -265,7 +287,11 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
265
287
  applied: true,
266
288
  entityType: "agent" as const,
267
289
  entityId: agent.id,
268
- entity: agent as Record<string, unknown>
290
+ entity: {
291
+ ...(agent as Record<string, unknown>),
292
+ sourceIssueIds,
293
+ delegationIntent: parsed.data.delegationIntent ?? null
294
+ }
269
295
  };
270
296
  }
271
297
 
@@ -385,6 +385,7 @@ export async function runHeartbeatForAgent(
385
385
  let transcriptLiveHighSignalCount = 0;
386
386
  let transcriptPersistFailureReported = false;
387
387
  let pluginFailureSummary: string[] = [];
388
+ const seenResultMessages = new Set<string>();
388
389
 
389
390
  const enqueueTranscriptEvent = (event: {
390
391
  kind: string;
@@ -401,6 +402,10 @@ export async function runHeartbeatForAgent(
401
402
  const signalLevel = normalizeTranscriptSignalLevel(event.signalLevel, event.kind);
402
403
  const groupKey = event.groupKey ?? defaultTranscriptGroupKey(event.kind, event.label);
403
404
  const source = event.source ?? "stdout";
405
+ const normalizedResultText = event.kind === "result" ? normalizeTranscriptResultText(event.text) : "";
406
+ if (event.kind === "result" && normalizedResultText.length > 0) {
407
+ seenResultMessages.add(normalizedResultText);
408
+ }
404
409
  transcriptLiveCount += 1;
405
410
  if (isUsefulTranscriptSignal(signalLevel)) {
406
411
  transcriptLiveUsefulCount += 1;
@@ -480,6 +485,10 @@ export async function runHeartbeatForAgent(
480
485
  if (!trimmed) {
481
486
  return;
482
487
  }
488
+ const normalized = normalizeTranscriptResultText(trimmed);
489
+ if (normalized.length > 0 && seenResultMessages.has(normalized)) {
490
+ return;
491
+ }
483
492
  enqueueTranscriptEvent({
484
493
  kind: "result",
485
494
  label,
@@ -993,6 +1002,20 @@ export async function runHeartbeatForAgent(
993
1002
  (transcriptLiveHighSignalCount < 2 && fallbackHighSignalCount > transcriptLiveHighSignalCount));
994
1003
  if (shouldAppendFallback) {
995
1004
  const createdAt = new Date();
1005
+ const dedupedFallbackMessages = fallbackMessages.filter((message) => {
1006
+ if (message.kind !== "result") {
1007
+ return true;
1008
+ }
1009
+ const normalized = normalizeTranscriptResultText(message.text);
1010
+ if (!normalized) {
1011
+ return true;
1012
+ }
1013
+ if (seenResultMessages.has(normalized)) {
1014
+ return false;
1015
+ }
1016
+ seenResultMessages.add(normalized);
1017
+ return true;
1018
+ });
996
1019
  const rows: Array<{
997
1020
  id: string;
998
1021
  sequence: number;
@@ -1004,7 +1027,7 @@ export async function runHeartbeatForAgent(
1004
1027
  groupKey: string | null;
1005
1028
  source: "trace_fallback";
1006
1029
  createdAt: Date;
1007
- }> = fallbackMessages.map((message) => ({
1030
+ }> = dedupedFallbackMessages.map((message) => ({
1008
1031
  id: nanoid(14),
1009
1032
  sequence: transcriptSequence++,
1010
1033
  kind: message.kind,
@@ -1711,6 +1734,11 @@ function normalizeTranscriptKind(
1711
1734
  return "system";
1712
1735
  }
1713
1736
 
1737
+ function normalizeTranscriptResultText(value: string | undefined) {
1738
+ const normalized = (value ?? "").replace(/\s+/g, " ").trim().toLowerCase();
1739
+ return normalized;
1740
+ }
1741
+
1714
1742
  function defaultTranscriptGroupKey(kind: string, label?: string) {
1715
1743
  if (kind === "tool_call" || kind === "tool_result") {
1716
1744
  return `tool:${(label ?? "unknown").trim().toLowerCase()}`;
@@ -2219,7 +2247,7 @@ function buildHeartbeatRuntimeEnv(input: {
2219
2247
  canHireAgents: boolean;
2220
2248
  }) {
2221
2249
  const apiBaseUrl = resolveControlPlaneApiBaseUrl();
2222
- const actorPermissions = ["issues:write", "agents:write"].join(",");
2250
+ const actorPermissions = ["issues:write", ...(input.canHireAgents ? ["agents:write"] : [])].join(",");
2223
2251
  const actorHeaders = JSON.stringify({
2224
2252
  "x-company-id": input.companyId,
2225
2253
  "x-actor-type": "agent",