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.
@@ -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,
@@ -29,7 +30,7 @@ export interface OnboardSeedSummary {
29
30
  const DEFAULT_COMPANY_NAME_ENV = "BOPO_DEFAULT_COMPANY_NAME";
30
31
  const DEFAULT_COMPANY_ID_ENV = "BOPO_DEFAULT_COMPANY_ID";
31
32
  const DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
32
- type AgentProvider = "codex" | "claude_code" | "cursor" | "opencode" | "shell";
33
+ type AgentProvider = "codex" | "claude_code" | "cursor" | "opencode" | "openai_api" | "anthropic_api" | "shell";
33
34
  const CEO_BOOTSTRAP_SUMMARY = "ceo bootstrap heartbeat";
34
35
  const STARTUP_PROJECT_NAME = "Leadership Setup";
35
36
  const CEO_STARTUP_TASK_TITLE = "Set up CEO operating files and hire founding engineer";
@@ -73,10 +74,15 @@ export async function ensureOnboardingSeed(input: {
73
74
  const resolvedCompanyName = companyRow.name;
74
75
  const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(db, companyId);
75
76
  await mkdir(defaultRuntimeCwd, { recursive: true });
77
+ const seedRuntimeEnv = resolveSeedRuntimeEnv(agentProvider);
76
78
  const defaultRuntimeConfig = normalizeRuntimeConfig({
77
79
  defaultRuntimeCwd,
78
80
  runtimeConfig: {
79
- runtimeEnv: resolveSeedRuntimeEnv(agentProvider)
81
+ runtimeEnv: seedRuntimeEnv,
82
+ runtimeModel: await resolveSeedRuntimeModel(agentProvider, {
83
+ defaultRuntimeCwd,
84
+ runtimeEnv: seedRuntimeEnv
85
+ })
80
86
  }
81
87
  });
82
88
  const agents = await listAgents(db, companyId);
@@ -177,16 +183,17 @@ async function ensureCeoStartupTask(
177
183
  "",
178
184
  "Stand up your leadership operating baseline before taking on additional delivery work.",
179
185
  "",
180
- "1. Create the folder `agents/ceo/` in the repository workspace.",
186
+ `1. Create the folder \`agents/${input.ceoId}/\` in the repository workspace.`,
181
187
  "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`",
188
+ ` - \`agents/${input.ceoId}/AGENTS.md\``,
189
+ ` - \`agents/${input.ceoId}/HEARTBEAT.md\``,
190
+ ` - \`agents/${input.ceoId}/SOUL.md\``,
191
+ ` - \`agents/${input.ceoId}/TOOLS.md\``,
186
192
  "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 ...\" }`",
193
+ ` - Supported simple body: \`{ "bootstrapPrompt": "Primary operating reference: agents/${input.ceoId}/AGENTS.md ..." }\``,
188
194
  " - 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.",
195
+ " - Prefer heredoc/stdin payloads (for example `curl --data-binary @- <<'JSON' ... JSON`) to avoid temp-file cleanup failures under runtime policy.",
196
+ ` - 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
197
  "4. To inspect your own agent record, use `GET /agents` and filter by your agent id. Do not call `GET /agents/:agentId`.",
191
198
  " - `GET /agents` uses envelope shape `{ \"ok\": true, \"data\": [...] }`; treat any other shape as failure.",
192
199
  " - 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 +255,22 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
248
255
  }
249
256
 
250
257
  function parseAgentProvider(value: unknown): AgentProvider | null {
251
- if (value === "codex" || value === "claude_code" || value === "cursor" || value === "opencode" || value === "shell") {
258
+ if (
259
+ value === "codex" ||
260
+ value === "claude_code" ||
261
+ value === "cursor" ||
262
+ value === "opencode" ||
263
+ value === "openai_api" ||
264
+ value === "anthropic_api" ||
265
+ value === "shell"
266
+ ) {
252
267
  return value;
253
268
  }
254
269
  return null;
255
270
  }
256
271
 
257
272
  function resolveSeedRuntimeEnv(agentProvider: AgentProvider): Record<string, string> {
258
- if (agentProvider === "codex") {
273
+ if (agentProvider === "codex" || agentProvider === "openai_api") {
259
274
  const key = (process.env.BOPO_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY)?.trim();
260
275
  if (!key) {
261
276
  return {};
@@ -264,7 +279,7 @@ function resolveSeedRuntimeEnv(agentProvider: AgentProvider): Record<string, str
264
279
  OPENAI_API_KEY: key
265
280
  };
266
281
  }
267
- if (agentProvider === "claude_code") {
282
+ if (agentProvider === "claude_code" || agentProvider === "anthropic_api") {
268
283
  const key = (process.env.BOPO_ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_API_KEY)?.trim();
269
284
  if (!key) {
270
285
  return {};
@@ -276,6 +291,36 @@ function resolveSeedRuntimeEnv(agentProvider: AgentProvider): Record<string, str
276
291
  return {};
277
292
  }
278
293
 
294
+ async function resolveSeedRuntimeModel(
295
+ agentProvider: AgentProvider,
296
+ input: { defaultRuntimeCwd: string; runtimeEnv: Record<string, string> }
297
+ ): Promise<string | undefined> {
298
+ if (agentProvider !== "opencode") {
299
+ return undefined;
300
+ }
301
+ const configured =
302
+ process.env.BOPO_OPENCODE_MODEL?.trim() ||
303
+ process.env.OPENCODE_MODEL?.trim();
304
+ try {
305
+ const discovered = await getAdapterModels("opencode", {
306
+ command: process.env.BOPO_OPENCODE_COMMAND?.trim() || "opencode",
307
+ cwd: input.defaultRuntimeCwd,
308
+ env: input.runtimeEnv
309
+ });
310
+ if (configured && discovered.some((entry) => entry.id === configured)) {
311
+ return configured;
312
+ }
313
+ if (discovered.length > 0) {
314
+ return discovered[0]!.id;
315
+ }
316
+ } catch {
317
+ if (configured) {
318
+ return configured;
319
+ }
320
+ }
321
+ return configured;
322
+ }
323
+
279
324
  function isBootstrapCeoRuntime(providerType: string, stateBlob: string | null) {
280
325
  if (providerType !== "shell") {
281
326
  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, "../../../");