bopodev-api 0.1.12 → 0.1.14

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.
@@ -0,0 +1,257 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod";
3
+ import { PluginManifestSchema } from "bopodev-contracts";
4
+ import {
5
+ createApprovalRequest,
6
+ deletePluginById,
7
+ deletePluginConfig,
8
+ listCompanyPluginConfigs,
9
+ listCompanies,
10
+ listPluginRuns,
11
+ listPlugins,
12
+ updatePluginConfig
13
+ } from "bopodev-db";
14
+ import type { AppContext } from "../context";
15
+ import { sendError, sendOk } from "../http";
16
+ import { requireCompanyScope } from "../middleware/company-scope";
17
+ import { deletePluginManifestFromFilesystem, writePluginManifestToFilesystem } from "../services/plugin-manifest-loader";
18
+ import { registerPluginManifest } from "../services/plugin-runtime";
19
+
20
+ const pluginConfigSchema = z.object({
21
+ enabled: z.boolean().optional(),
22
+ priority: z.number().int().min(0).max(1000).optional(),
23
+ config: z.record(z.string(), z.unknown()).default({}),
24
+ grantedCapabilities: z.array(z.string().min(1)).default([]),
25
+ requestApproval: z.boolean().default(true)
26
+ });
27
+ const pluginManifestCreateSchema = z.object({
28
+ manifestJson: z.string().min(2),
29
+ install: z.boolean().default(true)
30
+ });
31
+
32
+ const HIGH_RISK_CAPABILITIES = new Set(["network", "queue_publish", "issue_write", "write_memory"]);
33
+
34
+ export function createPluginsRouter(ctx: AppContext) {
35
+ const router = Router();
36
+ router.use(requireCompanyScope);
37
+
38
+ router.get("/", async (req, res) => {
39
+ const [catalog, configs] = await Promise.all([listPlugins(ctx.db), listCompanyPluginConfigs(ctx.db, req.companyId!)]);
40
+ const configByPluginId = new Map(configs.map((row) => [row.pluginId, row]));
41
+ return sendOk(
42
+ res,
43
+ catalog.map((plugin) => {
44
+ const config = configByPluginId.get(plugin.id);
45
+ const manifest = safeParseJsonObject(plugin.manifestJson) as Record<string, unknown>;
46
+ return {
47
+ id: plugin.id,
48
+ name: plugin.name,
49
+ description: typeof manifest.description === "string" ? manifest.description : null,
50
+ promptTemplate:
51
+ typeof manifest.runtime === "object" &&
52
+ manifest.runtime !== null &&
53
+ typeof (manifest.runtime as Record<string, unknown>).promptTemplate === "string"
54
+ ? ((manifest.runtime as Record<string, unknown>).promptTemplate as string)
55
+ : null,
56
+ version: plugin.version,
57
+ kind: plugin.kind,
58
+ runtimeType: plugin.runtimeType,
59
+ runtimeEntrypoint: plugin.runtimeEntrypoint,
60
+ hooks: safeParseStringArray(plugin.hooksJson),
61
+ capabilities: safeParseStringArray(plugin.capabilitiesJson),
62
+ companyConfig: config
63
+ ? {
64
+ enabled: config.enabled,
65
+ priority: config.priority,
66
+ config: safeParseJsonObject(config.configJson),
67
+ grantedCapabilities: safeParseStringArray(config.grantedCapabilitiesJson)
68
+ }
69
+ : null
70
+ };
71
+ })
72
+ );
73
+ });
74
+
75
+ router.put("/:pluginId", async (req, res) => {
76
+ const parsed = pluginConfigSchema.safeParse(req.body);
77
+ if (!parsed.success) {
78
+ return sendError(res, parsed.error.message, 422);
79
+ }
80
+ const pluginId = req.params.pluginId;
81
+ const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
82
+ const pluginExists = catalog.some((plugin) => plugin.id === pluginId);
83
+ if (!pluginExists) {
84
+ return sendError(res, `Plugin '${pluginId}' was not found. Restart API to refresh built-in plugins.`, 404);
85
+ }
86
+ const companyExists = companies.some((company) => company.id === req.companyId);
87
+ if (!companyExists) {
88
+ return sendError(res, `Company '${req.companyId}' does not exist.`, 404);
89
+ }
90
+ const riskyCaps = parsed.data.grantedCapabilities.filter((cap) => HIGH_RISK_CAPABILITIES.has(cap));
91
+ if (riskyCaps.length > 0 && parsed.data.requestApproval) {
92
+ const approvalId = await createApprovalRequest(ctx.db, {
93
+ companyId: req.companyId!,
94
+ requestedByAgentId: req.actor?.type === "agent" ? req.actor.id : null,
95
+ action: "grant_plugin_capabilities",
96
+ payload: {
97
+ pluginId,
98
+ enabled: parsed.data.enabled,
99
+ priority: parsed.data.priority,
100
+ grantedCapabilities: parsed.data.grantedCapabilities,
101
+ config: parsed.data.config
102
+ }
103
+ });
104
+ return sendOk(res, { approvalId, status: "pending" });
105
+ }
106
+ await updatePluginConfig(ctx.db, {
107
+ companyId: req.companyId!,
108
+ pluginId,
109
+ enabled: parsed.data.enabled,
110
+ priority: parsed.data.priority,
111
+ configJson: JSON.stringify(parsed.data.config),
112
+ grantedCapabilitiesJson: JSON.stringify(parsed.data.grantedCapabilities)
113
+ });
114
+ return sendOk(res, { ok: true });
115
+ });
116
+
117
+ router.post("/install-from-json", async (req, res) => {
118
+ const parsed = pluginManifestCreateSchema.safeParse(req.body);
119
+ if (!parsed.success) {
120
+ return sendError(res, parsed.error.message, 422);
121
+ }
122
+ let rawManifest: unknown;
123
+ try {
124
+ rawManifest = JSON.parse(parsed.data.manifestJson);
125
+ } catch {
126
+ return sendError(res, "manifestJson must be valid JSON.", 422);
127
+ }
128
+ const manifestParsed = PluginManifestSchema.safeParse(rawManifest);
129
+ if (!manifestParsed.success) {
130
+ return sendError(res, manifestParsed.error.message, 422);
131
+ }
132
+ const manifest = manifestParsed.data;
133
+ const [companies] = await Promise.all([listCompanies(ctx.db)]);
134
+ const companyExists = companies.some((company) => company.id === req.companyId);
135
+ if (!companyExists) {
136
+ return sendError(res, `Company '${req.companyId}' does not exist.`, 404);
137
+ }
138
+
139
+ const manifestPath = await writePluginManifestToFilesystem(manifest);
140
+ await registerPluginManifest(ctx.db, manifest);
141
+ if (parsed.data.install) {
142
+ await updatePluginConfig(ctx.db, {
143
+ companyId: req.companyId!,
144
+ pluginId: manifest.id,
145
+ enabled: false,
146
+ priority: 100,
147
+ configJson: "{}",
148
+ grantedCapabilitiesJson: "[]"
149
+ });
150
+ }
151
+ return sendOk(res, { ok: true, pluginId: manifest.id, manifestPath, installed: parsed.data.install });
152
+ });
153
+
154
+ router.post("/:pluginId/install", async (req, res) => {
155
+ const pluginId = req.params.pluginId;
156
+ const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
157
+ const plugin = catalog.find((item) => item.id === pluginId);
158
+ if (!plugin) {
159
+ return sendError(res, `Plugin '${pluginId}' was not found. Restart API to refresh built-in plugins.`, 404);
160
+ }
161
+ const companyExists = companies.some((company) => company.id === req.companyId);
162
+ if (!companyExists) {
163
+ return sendError(res, `Company '${req.companyId}' does not exist.`, 404);
164
+ }
165
+ await updatePluginConfig(ctx.db, {
166
+ companyId: req.companyId!,
167
+ pluginId,
168
+ enabled: false,
169
+ priority: 100,
170
+ configJson: "{}",
171
+ grantedCapabilitiesJson: "[]"
172
+ });
173
+ return sendOk(res, { ok: true, pluginId, installed: true, enabled: false });
174
+ });
175
+
176
+ router.delete("/:pluginId/install", async (req, res) => {
177
+ const pluginId = req.params.pluginId;
178
+ const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
179
+ const plugin = catalog.find((item) => item.id === pluginId);
180
+ if (!plugin) {
181
+ return sendError(res, `Plugin '${pluginId}' was not found.`, 404);
182
+ }
183
+ const companyExists = companies.some((company) => company.id === req.companyId);
184
+ if (!companyExists) {
185
+ return sendError(res, `Company '${req.companyId}' does not exist.`, 404);
186
+ }
187
+ await deletePluginConfig(ctx.db, {
188
+ companyId: req.companyId!,
189
+ pluginId
190
+ });
191
+ return sendOk(res, { ok: true, pluginId, installed: false });
192
+ });
193
+
194
+ router.delete("/:pluginId", async (req, res) => {
195
+ const pluginId = req.params.pluginId;
196
+ const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
197
+ const plugin = catalog.find((item) => item.id === pluginId);
198
+ if (!plugin) {
199
+ return sendError(res, `Plugin '${pluginId}' was not found.`, 404);
200
+ }
201
+ if (plugin.runtimeEntrypoint.startsWith("builtin:")) {
202
+ return sendError(res, `Plugin '${pluginId}' is built-in and cannot be deleted.`, 400);
203
+ }
204
+ const companyExists = companies.some((company) => company.id === req.companyId);
205
+ if (!companyExists) {
206
+ return sendError(res, `Company '${req.companyId}' does not exist.`, 404);
207
+ }
208
+ await deletePluginManifestFromFilesystem(pluginId);
209
+ await deletePluginById(ctx.db, pluginId);
210
+ return sendOk(res, { ok: true, pluginId, deleted: true });
211
+ });
212
+
213
+ router.get("/runs", async (req, res) => {
214
+ const pluginId = typeof req.query.pluginId === "string" ? req.query.pluginId : undefined;
215
+ const runId = typeof req.query.runId === "string" ? req.query.runId : undefined;
216
+ const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
217
+ const rows = await listPluginRuns(ctx.db, {
218
+ companyId: req.companyId!,
219
+ pluginId,
220
+ runId,
221
+ limit
222
+ });
223
+ return sendOk(
224
+ res,
225
+ rows.map((row) => ({
226
+ ...row,
227
+ diagnostics: safeParseJsonObject(row.diagnosticsJson)
228
+ }))
229
+ );
230
+ });
231
+
232
+ return router;
233
+ }
234
+
235
+ function safeParseStringArray(value: string | null | undefined) {
236
+ if (!value) {
237
+ return [] as string[];
238
+ }
239
+ try {
240
+ const parsed = JSON.parse(value) as unknown;
241
+ return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
242
+ } catch {
243
+ return [];
244
+ }
245
+ }
246
+
247
+ function safeParseJsonObject(value: string | null | undefined) {
248
+ if (!value) {
249
+ return {};
250
+ }
251
+ try {
252
+ const parsed = JSON.parse(value) as unknown;
253
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
254
+ } catch {
255
+ return {};
256
+ }
257
+ }
@@ -1,5 +1,6 @@
1
1
  import { pathToFileURL } from "node:url";
2
2
  import { mkdir } from "node:fs/promises";
3
+ import { getAdapterModels } from "bopodev-agent-sdk";
3
4
  import {
4
5
  bootstrapDatabase,
5
6
  createAgent,
@@ -16,6 +17,7 @@ import {
16
17
  } from "bopodev-db";
17
18
  import { normalizeRuntimeConfig, runtimeConfigToDb, runtimeConfigToStateBlobPatch } from "../lib/agent-config";
18
19
  import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
20
+ import { ensureCompanyModelPricingDefaults } from "../services/model-pricing";
19
21
 
20
22
  export interface OnboardSeedSummary {
21
23
  companyId: string;
@@ -29,7 +31,7 @@ export interface OnboardSeedSummary {
29
31
  const DEFAULT_COMPANY_NAME_ENV = "BOPO_DEFAULT_COMPANY_NAME";
30
32
  const DEFAULT_COMPANY_ID_ENV = "BOPO_DEFAULT_COMPANY_ID";
31
33
  const DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
32
- type AgentProvider = "codex" | "claude_code" | "cursor" | "opencode" | "shell";
34
+ type AgentProvider = "codex" | "claude_code" | "cursor" | "gemini_cli" | "opencode" | "openai_api" | "anthropic_api" | "shell";
33
35
  const CEO_BOOTSTRAP_SUMMARY = "ceo bootstrap heartbeat";
34
36
  const STARTUP_PROJECT_NAME = "Leadership Setup";
35
37
  const CEO_STARTUP_TASK_TITLE = "Set up CEO operating files and hire founding engineer";
@@ -73,10 +75,15 @@ export async function ensureOnboardingSeed(input: {
73
75
  const resolvedCompanyName = companyRow.name;
74
76
  const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(db, companyId);
75
77
  await mkdir(defaultRuntimeCwd, { recursive: true });
78
+ const seedRuntimeEnv = resolveSeedRuntimeEnv(agentProvider);
76
79
  const defaultRuntimeConfig = normalizeRuntimeConfig({
77
80
  defaultRuntimeCwd,
78
81
  runtimeConfig: {
79
- runtimeEnv: resolveSeedRuntimeEnv(agentProvider)
82
+ runtimeEnv: seedRuntimeEnv,
83
+ runtimeModel: await resolveSeedRuntimeModel(agentProvider, {
84
+ defaultRuntimeCwd,
85
+ runtimeEnv: seedRuntimeEnv
86
+ })
80
87
  }
81
88
  });
82
89
  const agents = await listAgents(db, companyId);
@@ -129,6 +136,7 @@ export async function ensureOnboardingSeed(input: {
129
136
  ceoId
130
137
  });
131
138
  }
139
+ await ensureCompanyModelPricingDefaults(db, companyId);
132
140
 
133
141
  return {
134
142
  companyId,
@@ -177,16 +185,17 @@ async function ensureCeoStartupTask(
177
185
  "",
178
186
  "Stand up your leadership operating baseline before taking on additional delivery work.",
179
187
  "",
180
- "1. Create the folder `agents/ceo/` in the repository workspace.",
188
+ `1. Create the folder \`agents/${input.ceoId}/\` in the repository workspace.`,
181
189
  "2. Author these files with your own voice and responsibilities:",
182
- " - `agents/ceo/AGENTS.md`",
183
- " - `agents/ceo/HEARTBEAT.md`",
184
- " - `agents/ceo/SOUL.md`",
185
- " - `agents/ceo/TOOLS.md`",
190
+ ` - \`agents/${input.ceoId}/AGENTS.md\``,
191
+ ` - \`agents/${input.ceoId}/HEARTBEAT.md\``,
192
+ ` - \`agents/${input.ceoId}/SOUL.md\``,
193
+ ` - \`agents/${input.ceoId}/TOOLS.md\``,
186
194
  "3. Save your operating-file reference on your own agent record via `PUT /agents/:agentId`.",
187
- " - Supported simple body: `{ \"bootstrapPrompt\": \"Primary operating reference: agents/ceo/AGENTS.md ...\" }`",
195
+ ` - Supported simple body: \`{ "bootstrapPrompt": "Primary operating reference: agents/${input.ceoId}/AGENTS.md ..." }\``,
188
196
  " - If using `runtimeConfig`, only `runtimeConfig.bootstrapPrompt` is supported there.",
189
- " - Use a temp JSON file or heredoc with `curl --data @file`; do not hand-escape multiline JSON.",
197
+ " - Prefer heredoc/stdin payloads (for example `curl --data-binary @- <<'JSON' ... JSON`) to avoid temp-file cleanup failures under runtime policy.",
198
+ ` - If you must use payload files, store them in \`agents/${input.ceoId}/tmp/\` (or OS temp via \`mktemp\`) and avoid chaining cleanup commands into critical task flow.`,
190
199
  "4. To inspect your own agent record, use `GET /agents` and filter by your agent id. Do not call `GET /agents/:agentId`.",
191
200
  " - `GET /agents` uses envelope shape `{ \"ok\": true, \"data\": [...] }`; treat any other shape as failure.",
192
201
  " - Deterministic filter: `jq -er --arg id \"$BOPODEV_AGENT_ID\" '.data | if type==\"array\" then . else error(\"invalid_agents_payload\") end | map(select((.id? // \"\") == $id))[0] | {id,name,role,bootstrapPrompt}'`",
@@ -248,14 +257,23 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
248
257
  }
249
258
 
250
259
  function parseAgentProvider(value: unknown): AgentProvider | null {
251
- if (value === "codex" || value === "claude_code" || value === "cursor" || value === "opencode" || value === "shell") {
260
+ if (
261
+ value === "codex" ||
262
+ value === "claude_code" ||
263
+ value === "cursor" ||
264
+ value === "gemini_cli" ||
265
+ value === "opencode" ||
266
+ value === "openai_api" ||
267
+ value === "anthropic_api" ||
268
+ value === "shell"
269
+ ) {
252
270
  return value;
253
271
  }
254
272
  return null;
255
273
  }
256
274
 
257
275
  function resolveSeedRuntimeEnv(agentProvider: AgentProvider): Record<string, string> {
258
- if (agentProvider === "codex") {
276
+ if (agentProvider === "codex" || agentProvider === "openai_api") {
259
277
  const key = (process.env.BOPO_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY)?.trim();
260
278
  if (!key) {
261
279
  return {};
@@ -264,7 +282,7 @@ function resolveSeedRuntimeEnv(agentProvider: AgentProvider): Record<string, str
264
282
  OPENAI_API_KEY: key
265
283
  };
266
284
  }
267
- if (agentProvider === "claude_code") {
285
+ if (agentProvider === "claude_code" || agentProvider === "anthropic_api") {
268
286
  const key = (process.env.BOPO_ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_API_KEY)?.trim();
269
287
  if (!key) {
270
288
  return {};
@@ -276,6 +294,36 @@ function resolveSeedRuntimeEnv(agentProvider: AgentProvider): Record<string, str
276
294
  return {};
277
295
  }
278
296
 
297
+ async function resolveSeedRuntimeModel(
298
+ agentProvider: AgentProvider,
299
+ input: { defaultRuntimeCwd: string; runtimeEnv: Record<string, string> }
300
+ ): Promise<string | undefined> {
301
+ if (agentProvider !== "opencode") {
302
+ return undefined;
303
+ }
304
+ const configured =
305
+ process.env.BOPO_OPENCODE_MODEL?.trim() ||
306
+ process.env.OPENCODE_MODEL?.trim();
307
+ try {
308
+ const discovered = await getAdapterModels("opencode", {
309
+ command: process.env.BOPO_OPENCODE_COMMAND?.trim() || "opencode",
310
+ cwd: input.defaultRuntimeCwd,
311
+ env: input.runtimeEnv
312
+ });
313
+ if (configured && discovered.some((entry) => entry.id === configured)) {
314
+ return configured;
315
+ }
316
+ if (discovered.length > 0) {
317
+ return discovered[0]!.id;
318
+ }
319
+ } catch {
320
+ if (configured) {
321
+ return configured;
322
+ }
323
+ }
324
+ return configured;
325
+ }
326
+
279
327
  function isBootstrapCeoRuntime(providerType: string, stateBlob: string | null) {
280
328
  if (providerType !== "shell") {
281
329
  return false;
package/src/server.ts CHANGED
@@ -3,13 +3,15 @@ import { dirname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { sql } from "drizzle-orm";
5
5
  import { config as loadDotenv } from "dotenv";
6
- import { bootstrapDatabase } from "bopodev-db";
6
+ import { bootstrapDatabase, listCompanies } from "bopodev-db";
7
7
  import { checkRuntimeCommandHealth } from "bopodev-agent-sdk";
8
8
  import type { RuntimeCommandHealth } from "bopodev-agent-sdk";
9
9
  import { createApp } from "./app";
10
10
  import { loadGovernanceRealtimeSnapshot } from "./realtime/governance";
11
11
  import { loadOfficeSpaceRealtimeSnapshot } from "./realtime/office-space";
12
+ import { loadHeartbeatRunsRealtimeSnapshot } from "./realtime/heartbeat-runs";
12
13
  import { attachRealtimeHub } from "./realtime/hub";
14
+ import { ensureBuiltinPluginsRegistered } from "./services/plugin-runtime";
13
15
  import { createHeartbeatScheduler } from "./worker/scheduler";
14
16
 
15
17
  loadApiEnv();
@@ -18,11 +20,21 @@ async function main() {
18
20
  const dbPath = process.env.BOPO_DB_PATH;
19
21
  const port = Number(process.env.PORT ?? 4020);
20
22
  const { db } = await bootstrapDatabase(dbPath);
23
+ const existingCompanies = await listCompanies(db);
24
+ await ensureBuiltinPluginsRegistered(
25
+ db,
26
+ existingCompanies.map((company) => company.id)
27
+ );
21
28
  const codexCommand = process.env.BOPO_CODEX_COMMAND ?? "codex";
29
+ const openCodeCommand = process.env.BOPO_OPENCODE_COMMAND ?? "opencode";
22
30
  const skipCodexPreflight = process.env.BOPO_SKIP_CODEX_PREFLIGHT === "1";
31
+ const skipOpenCodePreflight = process.env.BOPO_SKIP_OPENCODE_PREFLIGHT === "1";
23
32
  const codexHealthRequired =
24
33
  !skipCodexPreflight &&
25
34
  (process.env.BOPO_REQUIRE_CODEX_HEALTH === "1" || (await hasCodexAgentsConfigured(db)));
35
+ const openCodeHealthRequired =
36
+ !skipOpenCodePreflight &&
37
+ (process.env.BOPO_REQUIRE_OPENCODE_HEALTH === "1" || (await hasOpenCodeAgentsConfigured(db)));
26
38
  const getRuntimeHealth = async () => {
27
39
  const codex = codexHealthRequired
28
40
  ? await checkRuntimeCommandHealth(codexCommand, {
@@ -37,8 +49,22 @@ async function main() {
37
49
  ? "Skipped by configuration: BOPO_SKIP_CODEX_PREFLIGHT=1."
38
50
  : "Skipped: no Codex agents configured."
39
51
  };
52
+ const opencode = openCodeHealthRequired
53
+ ? await checkRuntimeCommandHealth(openCodeCommand, {
54
+ timeoutMs: 5_000
55
+ })
56
+ : {
57
+ command: openCodeCommand,
58
+ available: skipOpenCodePreflight ? false : true,
59
+ exitCode: null,
60
+ elapsedMs: 0,
61
+ error: skipOpenCodePreflight
62
+ ? "Skipped by configuration: BOPO_SKIP_OPENCODE_PREFLIGHT=1."
63
+ : "Skipped: no OpenCode agents configured."
64
+ };
40
65
  return {
41
- codex
66
+ codex,
67
+ opencode
42
68
  };
43
69
  };
44
70
  if (codexHealthRequired) {
@@ -49,12 +75,21 @@ async function main() {
49
75
  emitCodexPreflightWarning(startupCodexHealth);
50
76
  }
51
77
  }
78
+ if (openCodeHealthRequired) {
79
+ const startupOpenCodeHealth = await checkRuntimeCommandHealth(openCodeCommand, {
80
+ timeoutMs: 5_000
81
+ });
82
+ if (!startupOpenCodeHealth.available) {
83
+ emitOpenCodePreflightWarning(startupOpenCodeHealth);
84
+ }
85
+ }
52
86
 
53
87
  const server = createServer();
54
88
  const realtimeHub = attachRealtimeHub(server, {
55
89
  bootstrapLoaders: {
56
90
  governance: (companyId) => loadGovernanceRealtimeSnapshot(db, companyId),
57
- "office-space": (companyId) => loadOfficeSpaceRealtimeSnapshot(db, companyId)
91
+ "office-space": (companyId) => loadOfficeSpaceRealtimeSnapshot(db, companyId),
92
+ "heartbeat-runs": (companyId) => loadHeartbeatRunsRealtimeSnapshot(db, companyId)
58
93
  }
59
94
  });
60
95
  const app = createApp({ db, getRuntimeHealth, realtimeHub });
@@ -83,6 +118,16 @@ async function hasCodexAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapD
83
118
  return (result.rows ?? []).length > 0;
84
119
  }
85
120
 
121
+ async function hasOpenCodeAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"]) {
122
+ const result = await db.execute(sql`
123
+ SELECT id
124
+ FROM agents
125
+ WHERE provider_type = 'opencode'
126
+ LIMIT 1
127
+ `);
128
+ return (result.rows ?? []).length > 0;
129
+ }
130
+
86
131
  async function resolveSchedulerCompanyId(
87
132
  db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"],
88
133
  configuredCompanyId: string | null
@@ -125,6 +170,20 @@ function emitCodexPreflightWarning(health: RuntimeCommandHealth) {
125
170
  }
126
171
  }
127
172
 
173
+ function emitOpenCodePreflightWarning(health: RuntimeCommandHealth) {
174
+ const red = process.stderr.isTTY ? "\x1b[31m" : "";
175
+ const yellow = process.stderr.isTTY ? "\x1b[33m" : "";
176
+ const reset = process.stderr.isTTY ? "\x1b[0m" : "";
177
+ const symbol = `${red}✖${reset}`;
178
+ process.stderr.write(
179
+ `${symbol} ${yellow}OpenCode preflight failed${reset}: command '${health.command}' is unavailable.\n`
180
+ );
181
+ process.stderr.write(` Install OpenCode CLI or set BOPO_SKIP_OPENCODE_PREFLIGHT=1 for local dev.\n`);
182
+ if (process.env.BOPO_VERBOSE_STARTUP_WARNINGS === "1") {
183
+ process.stderr.write(` Details: ${JSON.stringify(health)}\n`);
184
+ }
185
+ }
186
+
128
187
  function loadApiEnv() {
129
188
  const sourceDir = dirname(fileURLToPath(import.meta.url));
130
189
  const repoRoot = resolve(sourceDir, "../../../");