bopodev-api 0.1.12 → 0.1.13

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.12",
3
+ "version": "0.1.13",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "files": [
@@ -13,16 +13,18 @@
13
13
  "dotenv": "^17.0.1",
14
14
  "drizzle-orm": "^0.44.5",
15
15
  "express": "^5.1.0",
16
+ "multer": "^2.1.1",
16
17
  "nanoid": "^5.1.5",
17
18
  "ws": "^8.19.0",
18
19
  "zod": "^4.1.5",
19
- "bopodev-contracts": "0.1.12",
20
- "bopodev-db": "0.1.12",
21
- "bopodev-agent-sdk": "0.1.12"
20
+ "bopodev-contracts": "0.1.13",
21
+ "bopodev-agent-sdk": "0.1.13",
22
+ "bopodev-db": "0.1.13"
22
23
  },
23
24
  "devDependencies": {
24
25
  "@types/cors": "^2.8.19",
25
26
  "@types/express": "^5.0.3",
27
+ "@types/multer": "^2.1.0",
26
28
  "@types/ws": "^8.18.1",
27
29
  "tsx": "^4.20.5"
28
30
  },
package/src/app.ts CHANGED
@@ -13,6 +13,7 @@ import { createHeartbeatRouter } from "./routes/heartbeats";
13
13
  import { createIssuesRouter } from "./routes/issues";
14
14
  import { createObservabilityRouter } from "./routes/observability";
15
15
  import { createProjectsRouter } from "./routes/projects";
16
+ import { createPluginsRouter } from "./routes/plugins";
16
17
  import { sendError } from "./http";
17
18
  import { attachRequestActor } from "./middleware/request-actor";
18
19
 
@@ -62,6 +63,7 @@ export function createApp(ctx: AppContext) {
62
63
  app.use("/governance", createGovernanceRouter(ctx));
63
64
  app.use("/heartbeats", createHeartbeatRouter(ctx));
64
65
  app.use("/observability", createObservabilityRouter(ctx));
66
+ app.use("/plugins", createPluginsRouter(ctx));
65
67
 
66
68
  app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
67
69
  if (error instanceof RepositoryValidationError) {
@@ -62,6 +62,18 @@ export function resolveAgentFallbackWorkspacePath(companyId: string, agentId: st
62
62
  return join(resolveBopoInstanceRoot(), "workspaces", companyId, "agents", agentId);
63
63
  }
64
64
 
65
+ export function resolveAgentMemoryRootPath(companyId: string, agentId: string) {
66
+ return join(resolveAgentFallbackWorkspacePath(companyId, agentId), "memory");
67
+ }
68
+
69
+ export function resolveAgentDurableMemoryPath(companyId: string, agentId: string) {
70
+ return join(resolveAgentMemoryRootPath(companyId, agentId), "life");
71
+ }
72
+
73
+ export function resolveAgentDailyMemoryPath(companyId: string, agentId: string) {
74
+ return join(resolveAgentMemoryRootPath(companyId, agentId), "memory");
75
+ }
76
+
65
77
  export function resolveStorageRoot() {
66
78
  return join(resolveBopoInstanceRoot(), "data", "storage");
67
79
  }
@@ -0,0 +1,35 @@
1
+ import { getAdapterModels } from "bopodev-agent-sdk";
2
+ import type { NormalizedRuntimeConfig } from "./agent-config";
3
+
4
+ export async function resolveOpencodeRuntimeModel(
5
+ providerType: string,
6
+ runtimeConfig: NormalizedRuntimeConfig
7
+ ): Promise<string | undefined> {
8
+ if (providerType !== "opencode") {
9
+ return runtimeConfig.runtimeModel;
10
+ }
11
+ if (runtimeConfig.runtimeModel?.trim()) {
12
+ return runtimeConfig.runtimeModel.trim();
13
+ }
14
+
15
+ const configured =
16
+ process.env.BOPO_OPENCODE_MODEL?.trim() ||
17
+ process.env.OPENCODE_MODEL?.trim() ||
18
+ undefined;
19
+ try {
20
+ const discovered = await getAdapterModels("opencode", {
21
+ command: runtimeConfig.runtimeCommand,
22
+ cwd: runtimeConfig.runtimeCwd,
23
+ env: runtimeConfig.runtimeEnv
24
+ });
25
+ if (configured && discovered.some((entry) => entry.id === configured)) {
26
+ return configured;
27
+ }
28
+ if (discovered.length > 0) {
29
+ return discovered[0]!.id;
30
+ }
31
+ } catch {
32
+ // Fall back to configured env default when discovery is unavailable.
33
+ }
34
+ return configured;
35
+ }
@@ -5,6 +5,7 @@ import {
5
5
  isInsidePath,
6
6
  normalizeAbsolutePath,
7
7
  resolveAgentFallbackWorkspacePath,
8
+ resolveAgentMemoryRootPath,
8
9
  resolveCompanyProjectsWorkspacePath
9
10
  } from "./instance-paths";
10
11
 
@@ -73,3 +74,7 @@ export function ensureRuntimeWorkspaceCompatible(projectWorkspacePath: string, r
73
74
  export function resolveAgentFallbackWorkspace(companyId: string, agentId: string) {
74
75
  return resolveAgentFallbackWorkspacePath(companyId, agentId);
75
76
  }
77
+
78
+ export function resolveAgentMemoryRoot(companyId: string, agentId: string) {
79
+ return resolveAgentMemoryRootPath(companyId, agentId);
80
+ }
@@ -0,0 +1,78 @@
1
+ import type {
2
+ HeartbeatRunRealtimeEvent,
3
+ RealtimeEventEnvelope,
4
+ RealtimeMessage
5
+ } from "bopodev-contracts";
6
+ import { listHeartbeatRunMessagesForRuns, listHeartbeatRuns, type BopoDb } from "bopodev-db";
7
+
8
+ export function createHeartbeatRunsRealtimeEvent(
9
+ companyId: string,
10
+ event: HeartbeatRunRealtimeEvent
11
+ ): Extract<RealtimeMessage, { kind: "event" }> {
12
+ return createRealtimeEvent(companyId, {
13
+ channel: "heartbeat-runs",
14
+ event
15
+ });
16
+ }
17
+
18
+ export async function loadHeartbeatRunsRealtimeSnapshot(
19
+ db: BopoDb,
20
+ companyId: string
21
+ ): Promise<Extract<RealtimeMessage, { kind: "event" }>> {
22
+ const runs = await listHeartbeatRuns(db, companyId, 8);
23
+ const transcriptsByRunId = await listHeartbeatRunMessagesForRuns(db, {
24
+ companyId,
25
+ runIds: runs.map((run) => run.id),
26
+ perRunLimit: 60
27
+ });
28
+ const transcripts = runs.map((run) => {
29
+ const result = transcriptsByRunId.get(run.id) ?? { items: [], nextCursor: null };
30
+ return {
31
+ runId: run.id,
32
+ messages: result.items.map((message) => ({
33
+ id: message.id,
34
+ runId: message.runId,
35
+ sequence: message.sequence,
36
+ kind: message.kind as
37
+ | "system"
38
+ | "assistant"
39
+ | "thinking"
40
+ | "tool_call"
41
+ | "tool_result"
42
+ | "result"
43
+ | "stderr",
44
+ label: message.label,
45
+ text: message.text,
46
+ payload: message.payloadJson,
47
+ signalLevel: (message.signalLevel as "high" | "medium" | "low" | "noise" | null) ?? undefined,
48
+ groupKey: message.groupKey,
49
+ source: (message.source as "stdout" | "stderr" | "trace_fallback" | null) ?? undefined,
50
+ createdAt: message.createdAt.toISOString()
51
+ })),
52
+ nextCursor: result.nextCursor
53
+ };
54
+ });
55
+
56
+ return createHeartbeatRunsRealtimeEvent(companyId, {
57
+ type: "runs.snapshot",
58
+ runs: runs.map((run) => ({
59
+ runId: run.id,
60
+ status: run.status as "started" | "completed" | "failed" | "skipped",
61
+ message: run.message ?? null,
62
+ startedAt: run.startedAt.toISOString(),
63
+ finishedAt: run.finishedAt?.toISOString() ?? null
64
+ })),
65
+ transcripts
66
+ });
67
+ }
68
+
69
+ function createRealtimeEvent(
70
+ companyId: string,
71
+ envelope: Extract<RealtimeEventEnvelope, { channel: "heartbeat-runs" }>
72
+ ): Extract<RealtimeMessage, { kind: "event" }> {
73
+ return {
74
+ kind: "event",
75
+ companyId,
76
+ ...envelope
77
+ };
78
+ }
@@ -25,7 +25,7 @@ export function attachRealtimeHub(
25
25
 
26
26
  wss.on("connection", async (socket, request) => {
27
27
  const subscription = getSubscription(request.url);
28
- if (!subscription) {
28
+ if (!subscription || !canSubscribeToCompany(request.headers, subscription.companyId)) {
29
29
  socket.close(1008, "Invalid realtime subscription");
30
30
  return;
31
31
  }
@@ -131,6 +131,42 @@ function getSubscription(requestUrl: string | undefined) {
131
131
  };
132
132
  }
133
133
 
134
+ function canSubscribeToCompany(
135
+ headers: Record<string, string | string[] | undefined>,
136
+ companyId: string
137
+ ) {
138
+ const actorType = readHeader(headers, "x-actor-type")?.toLowerCase();
139
+ const actorCompanies = parseCommaList(readHeader(headers, "x-actor-companies"));
140
+ const hasActorHeaders = Boolean(actorType || actorCompanies.length > 0);
141
+ const allowLocalBoardFallback = process.env.NODE_ENV !== "production" && process.env.BOPO_ALLOW_LOCAL_BOARD_FALLBACK !== "0";
142
+
143
+ if (!hasActorHeaders) {
144
+ return allowLocalBoardFallback;
145
+ }
146
+ if (actorType === "board") {
147
+ return true;
148
+ }
149
+ return actorCompanies.includes(companyId);
150
+ }
151
+
152
+ function readHeader(headers: Record<string, string | string[] | undefined>, name: string) {
153
+ const value = headers[name];
154
+ if (Array.isArray(value)) {
155
+ return value[0];
156
+ }
157
+ return value;
158
+ }
159
+
160
+ function parseCommaList(value: string | undefined) {
161
+ if (!value) {
162
+ return [] as string[];
163
+ }
164
+ return value
165
+ .split(",")
166
+ .map((entry) => entry.trim())
167
+ .filter((entry) => entry.length > 0);
168
+ }
169
+
134
170
  function buildSubscriptionKey(companyId: string, channel: RealtimeChannel) {
135
171
  return `${companyId}:${channel}`;
136
172
  }
@@ -427,7 +427,16 @@ function sortOccupants(occupants: OfficeOccupant[]) {
427
427
  }
428
428
 
429
429
  function normalizeProviderType(value: string): OfficeOccupant["providerType"] {
430
- return value === "claude_code" || value === "codex" || value === "http" || value === "shell" ? value : null;
430
+ return value === "claude_code" ||
431
+ value === "codex" ||
432
+ value === "cursor" ||
433
+ value === "opencode" ||
434
+ value === "openai_api" ||
435
+ value === "anthropic_api" ||
436
+ value === "http" ||
437
+ value === "shell"
438
+ ? value
439
+ : null;
431
440
  }
432
441
 
433
442
  function formatActionLabel(action: string) {
@@ -14,6 +14,7 @@ import {
14
14
  deleteAgent,
15
15
  getApprovalRequest,
16
16
  listAgents,
17
+ listApprovalRequests,
17
18
  updateAgent
18
19
  } from "bopodev-db";
19
20
  import type { AppContext } from "../context";
@@ -25,6 +26,7 @@ import {
25
26
  runtimeConfigToDb,
26
27
  runtimeConfigToStateBlobPatch
27
28
  } from "../lib/agent-config";
29
+ import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
28
30
  import { hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
29
31
  import { requireCompanyScope } from "../middleware/company-scope";
30
32
  import { requireBoardRole, requirePermission } from "../middleware/request-actor";
@@ -63,7 +65,16 @@ const updateAgentSchema = AgentUpdateRequestSchema.extend({
63
65
  });
64
66
 
65
67
  const runtimePreflightSchema = z.object({
66
- providerType: z.enum(["claude_code", "codex", "cursor", "opencode", "http", "shell"]),
68
+ providerType: z.enum([
69
+ "claude_code",
70
+ "codex",
71
+ "cursor",
72
+ "opencode",
73
+ "openai_api",
74
+ "anthropic_api",
75
+ "http",
76
+ "shell"
77
+ ]),
67
78
  runtimeConfig: z.record(z.string(), z.unknown()).optional(),
68
79
  ...legacyRuntimeConfigSchema.shape
69
80
  });
@@ -143,7 +154,15 @@ export function createAgentsRouter(ctx: AppContext) {
143
154
  runtimeConfig: req.body?.runtimeConfig,
144
155
  defaultRuntimeCwd
145
156
  });
146
- const typedProviderType = providerType as "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell";
157
+ const typedProviderType = providerType as
158
+ | "claude_code"
159
+ | "codex"
160
+ | "cursor"
161
+ | "opencode"
162
+ | "openai_api"
163
+ | "anthropic_api"
164
+ | "http"
165
+ | "shell";
147
166
  const models = await getAdapterModels(typedProviderType, {
148
167
  command: runtimeConfig.runtimeCommand,
149
168
  args: runtimeConfig.runtimeArgs,
@@ -245,6 +264,7 @@ export function createAgentsRouter(ctx: AppContext) {
245
264
  },
246
265
  defaultRuntimeCwd
247
266
  });
267
+ runtimeConfig.runtimeModel = await resolveOpencodeRuntimeModel(parsed.data.providerType, runtimeConfig);
248
268
  if (requiresRuntimeCwd(parsed.data.providerType) && !hasText(runtimeConfig.runtimeCwd)) {
249
269
  return sendError(res, "Runtime working directory is required for this runtime provider.", 422);
250
270
  }
@@ -253,6 +273,19 @@ export function createAgentsRouter(ctx: AppContext) {
253
273
  }
254
274
 
255
275
  if (parsed.data.requestApproval && isApprovalRequired("hire_agent")) {
276
+ const duplicate = await findDuplicateHireRequest(ctx.db, req.companyId!, {
277
+ role: parsed.data.role,
278
+ managerAgentId: parsed.data.managerAgentId ?? null
279
+ });
280
+ if (duplicate) {
281
+ return sendOk(res, {
282
+ queuedForApproval: false,
283
+ duplicate: true,
284
+ existingAgentId: duplicate.existingAgentId ?? null,
285
+ pendingApprovalId: duplicate.pendingApprovalId ?? null,
286
+ message: duplicateMessage(duplicate)
287
+ });
288
+ }
256
289
  const approvalId = await createApprovalRequest(ctx.db, {
257
290
  companyId: req.companyId!,
258
291
  action: "hire_agent",
@@ -363,6 +396,7 @@ export function createAgentsRouter(ctx: AppContext) {
363
396
  })
364
397
  : {})
365
398
  };
399
+ nextRuntime.runtimeModel = await resolveOpencodeRuntimeModel(effectiveProviderType, nextRuntime);
366
400
  if (!nextRuntime.runtimeCwd && defaultRuntimeCwd) {
367
401
  nextRuntime.runtimeCwd = defaultRuntimeCwd;
368
402
  }
@@ -522,3 +556,56 @@ function listUnsupportedAgentUpdateKeys(payload: unknown) {
522
556
  }
523
557
  return unsupported;
524
558
  }
559
+
560
+ async function findDuplicateHireRequest(
561
+ db: AppContext["db"],
562
+ companyId: string,
563
+ input: { role: string; managerAgentId: string | null }
564
+ ) {
565
+ const role = input.role.trim();
566
+ const managerAgentId = input.managerAgentId ?? null;
567
+ const agents = await listAgents(db, companyId);
568
+ const existingAgent = agents.find(
569
+ (agent) =>
570
+ agent.role === role &&
571
+ (agent.managerAgentId ?? null) === managerAgentId &&
572
+ agent.status !== "terminated"
573
+ );
574
+ const approvals = await listApprovalRequests(db, companyId);
575
+ const pendingApproval = approvals.find((approval) => {
576
+ if (approval.status !== "pending" || approval.action !== "hire_agent") {
577
+ return false;
578
+ }
579
+ const payload = parseApprovalPayload(approval.payloadJson);
580
+ return payload.role === role && (payload.managerAgentId ?? null) === managerAgentId;
581
+ });
582
+ if (!existingAgent && !pendingApproval) {
583
+ return null;
584
+ }
585
+ return {
586
+ existingAgentId: existingAgent?.id ?? null,
587
+ pendingApprovalId: pendingApproval?.id ?? null
588
+ };
589
+ }
590
+
591
+ function parseApprovalPayload(payloadJson: string): { role?: string; managerAgentId?: string | null } {
592
+ try {
593
+ const parsed = JSON.parse(payloadJson) as Record<string, unknown>;
594
+ return {
595
+ role: typeof parsed.role === "string" ? parsed.role : undefined,
596
+ managerAgentId: typeof parsed.managerAgentId === "string" ? parsed.managerAgentId : null
597
+ };
598
+ } catch {
599
+ return {};
600
+ }
601
+ }
602
+
603
+ function duplicateMessage(input: { existingAgentId: string | null; pendingApprovalId: string | null }) {
604
+ if (input.existingAgentId && input.pendingApprovalId) {
605
+ return `Duplicate hire request blocked: existing agent ${input.existingAgentId} and pending approval ${input.pendingApprovalId}.`;
606
+ }
607
+ if (input.existingAgentId) {
608
+ return `Duplicate hire request blocked: existing agent ${input.existingAgentId}.`;
609
+ }
610
+ return `Duplicate hire request blocked: pending approval ${input.pendingApprovalId}.`;
611
+ }
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { createCompany, deleteCompany, listCompanies, updateCompany } from "bopodev-db";
4
4
  import type { AppContext } from "../context";
5
5
  import { sendError, sendOk } from "../http";
6
+ import { ensureCompanyBuiltinPluginDefaults } from "../services/plugin-runtime";
6
7
 
7
8
  const createCompanySchema = z.object({
8
9
  name: z.string().min(1),
@@ -30,6 +31,7 @@ export function createCompaniesRouter(ctx: AppContext) {
30
31
  return sendError(res, parsed.error.message, 422);
31
32
  }
32
33
  const company = await createCompany(ctx.db, parsed.data);
34
+ await ensureCompanyBuiltinPluginDefaults(ctx.db, company.id);
33
35
  return sendOk(res, company);
34
36
  });
35
37
 
@@ -163,11 +163,18 @@ export function createGovernanceRouter(ctx: AppContext) {
163
163
  });
164
164
 
165
165
  if (resolution.execution.applied && resolution.execution.entityType && resolution.execution.entityId) {
166
+ const eventType =
167
+ resolution.action === "grant_plugin_capabilities"
168
+ ? "plugin.capabilities_granted_from_approval"
169
+ : resolution.execution.entityType === "agent"
170
+ ? "agent.hired_from_approval"
171
+ : resolution.execution.entityType === "goal"
172
+ ? "goal.activated_from_approval"
173
+ : "memory.promoted_from_approval";
166
174
  await appendAuditEvent(ctx.db, {
167
175
  companyId: req.companyId!,
168
176
  actorType: "human",
169
- eventType:
170
- resolution.execution.entityType === "agent" ? "agent.hired_from_approval" : "goal.activated_from_approval",
177
+ eventType,
171
178
  entityType: resolution.execution.entityType,
172
179
  entityId: resolution.execution.entityId,
173
180
  payload: resolution.execution.entity ?? { id: resolution.execution.entityId }
@@ -79,7 +79,8 @@ export function createHeartbeatRouter(ctx: AppContext) {
79
79
  const stopResult = await stopHeartbeatRun(ctx.db, req.companyId!, parsed.data.runId, {
80
80
  requestId: req.requestId,
81
81
  trigger: "manual",
82
- actorId: req.actor?.id ?? undefined
82
+ actorId: req.actor?.id ?? undefined,
83
+ realtimeHub: ctx.realtimeHub
83
84
  });
84
85
  if (!stopResult.ok) {
85
86
  if (stopResult.reason === "not_found") {