diff --git a/apps/app/src/app/lib/desktop-tauri.ts b/apps/app/src/app/lib/desktop-tauri.ts index 951dff12..d67e5b1e 100644 --- a/apps/app/src/app/lib/desktop-tauri.ts +++ b/apps/app/src/app/lib/desktop-tauri.ts @@ -648,6 +648,14 @@ export async function engineInfo(): Promise { return invoke("engine_info"); } +export async function runtimeBootstrap(): Promise { + return { + ok: true, + skipped: true, + reason: "unsupported-runtime", + }; +} + export async function engineDoctor(options?: { preferSidecar?: boolean; opencodeBinPath?: string | null; @@ -668,6 +676,7 @@ export async function pickDirectory(options?: { title: options?.title, defaultPath: options?.defaultPath, directory: true, + canCreateDirectories: true, multiple: options?.multiple, }); } diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index 48ff91a9..fb8f6544 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -243,6 +243,7 @@ const { sandboxDebugProbe, openworkServerInfo, openworkServerRestart, + runtimeBootstrap, engineInfo, engineDoctor, pickDirectory, @@ -309,6 +310,7 @@ export { sandboxDebugProbe, openworkServerInfo, openworkServerRestart, + runtimeBootstrap, engineInfo, engineDoctor, pickDirectory, diff --git a/apps/app/src/react-app/shell/desktop-runtime-boot.ts b/apps/app/src/react-app/shell/desktop-runtime-boot.ts index 54968f14..46a5e303 100644 --- a/apps/app/src/react-app/shell/desktop-runtime-boot.ts +++ b/apps/app/src/react-app/shell/desktop-runtime-boot.ts @@ -7,6 +7,7 @@ import { openworkServerInfo, orchestratorWorkspaceActivate, resolveWorkspaceListSelectedId, + runtimeBootstrap, workspaceBootstrap, } from "../../app/lib/desktop"; import { ingestMigrationSnapshotOnElectronBoot } from "../../app/lib/migration"; @@ -82,6 +83,54 @@ export function useDesktopRuntimeBoot() { return; } + if (isElectronRuntime()) { + setPhase("starting-engine", "Starting your workspace"); + const boot = (await runtimeBootstrap().catch((error) => ({ + ok: false, + error: error instanceof Error ? error.message : safeStringify(error), + }))) as { + ok?: boolean; + skipped?: boolean; + error?: string; + engine?: { baseUrl?: string | null }; + openworkServer?: { + baseUrl?: string | null; + ownerToken?: string | null; + clientToken?: string | null; + port?: number | null; + remoteAccessEnabled?: boolean; + }; + }; + + if (boot.ok === false) { + setError(boot.error || "Failed to start OpenWork runtime"); + return; + } + + if (boot.engine?.baseUrl) { + setActive(boot.engine.baseUrl); + } + const serverInfo = boot.openworkServer; + if (serverInfo?.baseUrl) { + writeOpenworkServerSettings({ + urlOverride: serverInfo.baseUrl, + token: + serverInfo.ownerToken?.trim() || + serverInfo.clientToken?.trim() || + undefined, + portOverride: serverInfo.port ?? undefined, + remoteAccessEnabled: serverInfo.remoteAccessEnabled === true, + }); + try { + window.dispatchEvent(new CustomEvent("openwork-server-settings-changed")); + } catch { + /* ignore */ + } + } + markReady(); + return; + } + // FAST PATH ───────────────────────────────────────────────────── // Cheap status probe: if engine is already running just publish the // current openwork-server base URL + token and finish in <1s. diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index 1951c126..24d2c7cc 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -383,6 +383,56 @@ const runtimeManager = createRuntimeManager({ .filter(Boolean), }); +let runtimeDisposedForQuit = false; +let runtimeBootstrapPromise = null; + +async function disposeRuntimeBeforeQuit() { + if (runtimeDisposedForQuit) return; + runtimeDisposedForQuit = true; + await runtimeManager.dispose().catch(() => undefined); +} + +async function bootRuntimeForSelectedWorkspace() { + const list = await readWorkspaceState(); + const selectedId = list.selectedId || list.activeId || list.workspaces[0]?.id || ""; + const workspace = selectedId + ? list.workspaces.find((entry) => entry?.id === selectedId) + : list.workspaces[0]; + const workspaceRoot = String(workspace?.path ?? "").trim(); + if (!workspaceRoot || workspace?.workspaceType === "remote") { + return { ok: true, skipped: true, reason: "no-local-workspace" }; + } + + const workspacePaths = []; + for (const entry of list.workspaces) { + if (entry?.workspaceType === "remote") continue; + const workspacePath = String(entry?.path ?? "").trim(); + if (workspacePath && !workspacePaths.includes(workspacePath)) workspacePaths.push(workspacePath); + } + if (!workspacePaths.includes(workspaceRoot)) workspacePaths.unshift(workspaceRoot); + + const engine = await runtimeManager.engineStart(workspaceRoot, { + runtime: "openwork-orchestrator", + workspacePaths, + }); + await runtimeManager.orchestratorWorkspaceActivate({ + workspacePath: workspaceRoot, + name: workspace.name ?? workspace.displayName ?? null, + }).catch(() => undefined); + const openworkServer = await runtimeManager.openworkServerInfo(); + return { ok: true, skipped: false, engine, openworkServer, workspaceId: workspace.id ?? null }; +} + +function ensureRuntimeBootstrap() { + if (!runtimeBootstrapPromise) { + runtimeBootstrapPromise = bootRuntimeForSelectedWorkspace().catch((error) => ({ + ok: false, + error: error instanceof Error ? error.message : String(error), + })); + } + return runtimeBootstrapPromise; +} + function makeWorkspaceId(kind, value) { return `${kind}_${createHash("sha1").update(String(value)).digest("hex").slice(0, 12)}`; } @@ -877,6 +927,12 @@ async function handleDesktopInvoke(event, command, ...args) { const options = args[1] ?? {}; return runtimeManager.engineStart(projectDir, options); } + case "prepareFreshRuntime": + return runtimeManager.prepareFreshRuntime(); + case "runtimeBootstrap": + return ensureRuntimeBootstrap(); + case "runtimeStatus": + return runtimeManager.runtimeStatus(); case "engineStop": return runtimeManager.engineStop(); case "engineRestart": @@ -931,7 +987,7 @@ async function handleDesktopInvoke(event, command, ...args) { const result = await dialog.showOpenDialog(activeWindowFromEvent(event), { title: options.title, defaultPath: options.defaultPath, - properties: ["openDirectory", ...(options.multiple ? ["multiSelections"] : [])], + properties: ["openDirectory", "createDirectory", ...(options.multiple ? ["multiSelections"] : [])], }); if (result.canceled) return null; return options.multiple ? result.filePaths : (result.filePaths[0] ?? null); @@ -1346,6 +1402,12 @@ ipcMain.handle("openwork:updater:installAndRestart", async () => { if (!app.requestSingleInstanceLock()) { app.quit(); } else { + app.on("before-quit", (event) => { + if (runtimeDisposedForQuit) return; + event.preventDefault(); + void disposeRuntimeBeforeQuit().finally(() => app.quit()); + }); + app.on("second-instance", async (_event, argv) => { const win = await createMainWindow(); if (win.isMinimized()) { @@ -1363,9 +1425,15 @@ if (!app.requestSingleInstanceLock()) { }); app.whenReady().then(async () => { + await runtimeManager.prepareFreshRuntime().catch(() => undefined); + // Copy Tauri workspace state on first launch so the Electron sidebar // reflects the exact workspace list users see in the Tauri app today. await migrateLegacyWorkspaceStateIfNeeded(); + runtimeBootstrapPromise = bootRuntimeForSelectedWorkspace().catch((error) => ({ + ok: false, + error: error instanceof Error ? error.message : String(error), + })); queueDeepLinks(forwardedDeepLinks(process.argv)); const win = await createMainWindow(); diff --git a/apps/desktop/electron/runtime.mjs b/apps/desktop/electron/runtime.mjs index 08d3bc86..0566379a 100644 --- a/apps/desktop/electron/runtime.mjs +++ b/apps/desktop/electron/runtime.mjs @@ -292,6 +292,7 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths // stopAllRuntimeChildren kills the previous call's freshly-spawned // orchestrator daemon, and the prior call then times out its /health probe. let runtimeLifecycleQueue = Promise.resolve(); + let lifecycleState = "idle"; function withRuntimeLifecycle(fn) { const next = runtimeLifecycleQueue.then(fn, fn); runtimeLifecycleQueue = next.catch(() => {}); @@ -435,17 +436,9 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths } async function resolveOpenworkPort(host, workspaceKey) { - const preferred = await readPreferredOpenworkPort(workspaceKey); - if (preferred && (await portAvailable(host, preferred))) { - return preferred; - } - - for (let port = OPENWORK_SERVER_PORT_RANGE_START; port <= OPENWORK_SERVER_PORT_RANGE_END; port += 1) { - if (await portAvailable(host, port)) { - return port; - } - } - + // Use a fresh port every boot. Persisted preferred ports made prod starts + // fragile when an old sidecar held the previous port or shutdown was + // unclean; Electron publishes the chosen URL to React after boot. return findFreePort(host); } @@ -676,6 +669,55 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths return child; } + function processMatchesSidecar(command) { + const value = String(command ?? ""); + return sidecarDirs.some((dir) => value.includes(dir)) && + ( + value.includes("openwork-orchestrator") || + value.includes("openwork-server") || + value.includes("opencode serve") || + value.includes("opencode-router") + ); + } + + function killProcessId(pid, signal = "SIGTERM") { + if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return; + try { + process.kill(pid, signal); + } catch { + // Process already exited or is not ours. + } + } + + async function cleanupPackagedSidecars() { + if (!app.isPackaged) return; + + // First ask the previously recorded orchestrator daemon to shut itself and + // its OpenCode child down. This handles the happy path without relying on + // process-list parsing. + await requestOrchestratorShutdown(orchestratorState.dataDir || orchestratorDataDir()).catch(() => false); + await new Promise((resolve) => setTimeout(resolve, 300)); + + // Safety net: an unclean Electron quit can orphan sidecars. Packaged builds + // should always own a fresh runtime per app launch, so remove any leftover + // sidecars from this app bundle before choosing ports for the new runtime. + const result = spawnSync("ps", ["-Ao", "pid=,command="], { encoding: "utf8" }); + const rows = String(result.stdout ?? "").split(/\r?\n/); + const pids = []; + for (const row of rows) { + const match = row.match(/^\s*(\d+)\s+(.+)$/); + if (!match) continue; + const pid = Number(match[1]); + const command = match[2] ?? ""; + if (processMatchesSidecar(command)) pids.push(pid); + } + for (const pid of pids) killProcessId(pid, "SIGTERM"); + if (pids.length > 0) { + await new Promise((resolve) => setTimeout(resolve, 500)); + for (const pid of pids) killProcessId(pid, "SIGKILL"); + } + } + async function stopChild(state, options = {}) { const child = state.child; state.child = null; @@ -981,6 +1023,13 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths Object.assign(routerState, createRouterState()); } + async function prepareFreshRuntime() { + lifecycleState = "cleaning"; + await stopAllRuntimeChildren(); + await cleanupPackagedSidecars(); + lifecycleState = "idle"; + } + async function ensureRouterAndOpenwork(options) { const routerHealthPort = await resolveRouterHealthPort().catch(() => null); try { @@ -1018,28 +1067,37 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths } await mkdir(safeProjectDir, { recursive: true }); await ensureOpencodeConfig(safeProjectDir); - await stopAllRuntimeChildren(); + await prepareFreshRuntime(); const workspacePaths = [safeProjectDir, ...((options.workspacePaths ?? []).filter(Boolean))].filter( (value, index, list) => list.indexOf(value) === index, ); const runtime = options.runtime ?? ORCHESTRATOR_RUNTIME; - const snapshot = runtime === ORCHESTRATOR_RUNTIME - ? await startOrchestratorRuntime(safeProjectDir, options) - : await startDirectRuntime(safeProjectDir, options); + try { + lifecycleState = "starting"; + const snapshot = runtime === ORCHESTRATOR_RUNTIME + ? await startOrchestratorRuntime(safeProjectDir, options) + : await startDirectRuntime(safeProjectDir, options); - await ensureRouterAndOpenwork({ - projectDir: safeProjectDir, - workspacePaths, - remoteAccessEnabled: options.openworkRemoteAccess === true, - }); + await ensureRouterAndOpenwork({ + projectDir: safeProjectDir, + workspacePaths, + remoteAccessEnabled: options.openworkRemoteAccess === true, + }); - return snapshot; + lifecycleState = "healthy"; + return snapshot; + } catch (error) { + lifecycleState = "error"; + throw error; + } } async function engineStop() { + lifecycleState = "stopping"; await stopAllRuntimeChildren(); + lifecycleState = "idle"; return snapshotEngineState(engineState); } @@ -1057,31 +1115,16 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths } async function engineInfo() { - if (engineState.runtime === ORCHESTRATOR_RUNTIME && !engineState.child && !engineState.childExited) { - return snapshotEngineState(engineState); - } + return { ...snapshotEngineState(engineState), lifecycleState }; + } - if (engineState.runtime === ORCHESTRATOR_RUNTIME && !snapshotEngineState(engineState).running) { - const dataDir = orchestratorState.dataDir || orchestratorDataDir(); - const stateFile = await readOrchestratorStateFile(dataDir); - const auth = await readOrchestratorAuthFile(dataDir); - const opencode = stateFile?.opencode; - return { - running: Boolean(stateFile?.daemon && opencode), - runtime: ORCHESTRATOR_RUNTIME, - baseUrl: opencode?.port ? `http://127.0.0.1:${opencode.port}` : null, - projectDir: auth?.projectDir ?? engineState.projectDir, - hostname: opencode ? "127.0.0.1" : null, - port: opencode?.port ?? null, - opencodeUsername: auth?.opencodeUsername ?? engineState.opencodeUsername, - opencodePassword: auth?.opencodePassword ?? engineState.opencodePassword, - pid: opencode?.pid ?? null, - lastStdout: orchestratorState.lastStdout, - lastStderr: orchestratorState.lastStderr, - }; - } - - return snapshotEngineState(engineState); + async function runtimeStatus() { + return { + lifecycleState, + engine: await engineInfo(), + openworkServer: snapshotOpenworkServerState(openworkServerState), + router: snapshotRouterState(routerState), + }; } async function openworkServerInfo() { @@ -1540,6 +1583,9 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths engineStart: (projectDir, options) => withRuntimeLifecycle(() => engineStart(projectDir, options)), engineStop: () => withRuntimeLifecycle(() => engineStop()), engineRestart: (options) => withRuntimeLifecycle(() => engineRestart(options)), + prepareFreshRuntime: () => withRuntimeLifecycle(() => prepareFreshRuntime()), + dispose: () => withRuntimeLifecycle(() => stopAllRuntimeChildren()), + runtimeStatus, engineInfo, engineInstall, openworkServerInfo, diff --git a/apps/server/src/workspace-init.ts b/apps/server/src/workspace-init.ts index 0aff991b..349790fb 100644 --- a/apps/server/src/workspace-init.ts +++ b/apps/server/src/workspace-init.ts @@ -1,124 +1,20 @@ import { basename, join } from "node:path"; import { readFile, writeFile } from "node:fs/promises"; -import { upsertSkill } from "./skills.js"; -import { upsertCommand } from "./commands.js"; -import { readJsoncFile, writeJsoncFile } from "./jsonc.js"; import { ensureDir, exists } from "./utils.js"; import { ApiError } from "./errors.js"; -import { openworkConfigPath, opencodeConfigPath, projectCommandsDir, projectSkillsDir } from "./workspace-files.js"; - -const WORKSPACE_GUIDE = `--- -name: workspace-guide -description: Workspace guide to introduce OpenWork and onboard new users. ---- - -# Welcome to OpenWork - -Hi, I'm Ben and this is OpenWork. It's an open-source alternative to Claude's cowork. It helps you work on your files with AI and automate the mundane tasks so you don't have to. - -Before we start, use the question tool to ask: -"Are you more technical or non-technical? I'll tailor the explanation." - -## If the person is non-technical -OpenWork feels like a chat app, but it can safely work with the files you allow. Put files in this workspace and I can summarize them, create new ones, or help organize them. - -Try: -- "Summarize the files in this workspace." -- "Create a checklist for my week." -- "Draft a short summary from this document." - -## Skills and plugins (simple) -Skills add new capabilities. Plugins add advanced features like scheduling or browser automation. We can add them later when you're ready. - -## If the person is technical -OpenWork is a GUI for OpenCode. Everything that works in OpenCode works here. - -Most reliable setup today: -1) Install OpenCode from opencode.ai -2) Configure providers there (models and API keys) -3) Come back to OpenWork and start a session - -Skills: -- Install from the Skills tab, or add them to this workspace. -- Docs: https://opencode.ai/docs/skills - -Plugins: -- Configure in opencode.json or use the Plugins tab. -- Docs: https://opencode.ai/docs/plugins/ - -MCP servers: -- Add external tools via opencode.json. -- Docs: https://opencode.ai/docs/mcp-servers/ - -Config reference: -- Docs: https://opencode.ai/docs/config/ - -End with two friendly next actions to try in OpenWork. -`; - -const GET_STARTED_SKILL = `--- -name: get-started -description: Guide users through the get started setup and Chrome DevTools demo. ---- - -## When to use -- Always load this skill when the user says "get started". - -## What to do -- Reply with these four lines, exactly and in order: - 1) hey there welcome this is openwork - 2) we've pre-configured you with a couple tools - 3) Get Started - 4) write "hey go on google.com" - -## Then -- If the user writes "go on google.com" (or "hey go on google.com"), use the chrome-devtools MCP to open the site. -- After the navigation completes, reply: "I'm on " where is the final URL or page title they asked for. -`; +import { openworkConfigPath, opencodeConfigPath } from "./workspace-files.js"; +import { readJsoncFile, writeJsoncFile } from "./jsonc.js"; const OPENWORK_AGENT = `--- -description: OpenWork default agent (safe, mobile-first, self-referential) +description: OpenWork default agent mode: primary temperature: 0.2 --- You are OpenWork. -When the user refers to "you", they mean the OpenWork app and the current workspace. - -Your job: -- Help the user work on files safely. -- Automate repeatable work. -- Keep behavior portable and reproducible. - -Memory (two kinds) -1) Behavior memory (shareable, in git) -- ".opencode/skills/**" -- ".opencode/agents/**" -- repo docs - -2) Private memory (never commit) -- Tokens, IDs, credentials -- Local DBs/logs/config files (gitignored) -- Notion pages/databases (if configured via MCP) - -Hard rule: never copy private memory into repo files verbatim. Store only redacted summaries, schemas/templates, and stable pointers. - -Reconstruction-first -- Do not assume env vars or prior setup. -- If required state is missing, ask one targeted question. -- After the user provides it, store it in private memory and continue. - -Verification-first -- If you change code, run the smallest meaningful test or smoke check. -- If you touch UI or remote behavior, validate end-to-end and capture logs on failure. - -Incremental adoption loop -- Do the task once end-to-end. -- If steps repeat, factor them into a skill. -- If the work becomes ongoing, create/refine an agent role. -- If it should run regularly, schedule it and store outputs in private memory. +Help the user work on files safely from this workspace. Prefer clear, practical steps. If required setup or credentials are missing, ask one targeted question and continue once provided. `; type WorkspaceOpenworkConfig = { @@ -129,180 +25,18 @@ type WorkspaceOpenworkConfig = { preset?: string | null; } | null; authorizedRoots: string[]; - blueprint?: Record | null; reload?: { auto?: boolean; resume?: boolean; } | null; }; -function buildDefaultWorkspaceBlueprint(_preset: string): Record { - return { - emptyState: { - title: "What do you want to do?", - body: "Pick a starting point or just type below.", - starters: [ - { - id: "csv-help", - kind: "prompt", - title: "Work on a CSV", - description: "Clean up or generate spreadsheet data.", - prompt: "Help me create or edit CSV files on this computer.", - }, - { - id: "starter-connect-openai", - kind: "action", - title: "Connect ChatGPT", - description: "Add your OpenAi provider so ChatGPT models are ready in new sessions.", - action: "connect-openai", - }, - { - id: "browser-automation", - kind: "session", - title: "Automate Chrome", - description: "Start a browser automation conversation right away.", - prompt: "Help me connect to Chrome and automate a repetitive task.", - }, - ], - }, - sessions: [ - { - id: "welcome-to-openwork", - title: "Welcome to OpenWork", - openOnFirstLoad: true, - messages: [ - { - role: "assistant", - text: - "Hi welcome to OpenWork!\n\nPeople use us to write .csv files on their computer, connect to Chrome and automate repetitive tasks, and sync contacts to Notion.\n\nBut the only limit is your imagination.\n\nWhat would you want to do?", - }, - ], - }, - { - id: "csv-playbook", - title: "CSV workflow ideas", - messages: [ - { - role: "assistant", - text: "I can help you generate, clean, merge, and summarize CSV files. What kind of CSV work do you want to automate?", - }, - { - role: "user", - text: "I want to combine exports from multiple tools into one clean CSV.", - }, - ], - }, - ], - }; -} - function normalizePreset(preset: string | null | undefined): string { const trimmed = preset?.trim() ?? ""; if (!trimmed) return "starter"; return trimmed; } -function mergePlugins(existing: string[], required: string[]): string[] { - const next = existing.slice(); - for (const plugin of required) { - if (!next.includes(plugin)) { - next.push(plugin); - } - } - return next; -} - -async function ensureOpenworkAgent(workspaceRoot: string): Promise { - const agentsDir = join(workspaceRoot, ".opencode", "agents"); - const agentPath = join(agentsDir, "openwork.md"); - if (await exists(agentPath)) return; - await ensureDir(agentsDir); - await writeFile(agentPath, OPENWORK_AGENT.endsWith("\n") ? OPENWORK_AGENT : `${OPENWORK_AGENT}\n`, "utf8"); -} - -async function ensureStarterSkills(workspaceRoot: string, preset: string): Promise { - await ensureDir(projectSkillsDir(workspaceRoot)); - await upsertSkill(workspaceRoot, { - name: "workspace-guide", - description: "Workspace guide to introduce OpenWork and onboard new users.", - content: WORKSPACE_GUIDE, - }); - if (preset === "starter") { - await upsertSkill(workspaceRoot, { - name: "get-started", - description: "Guide users through the get started setup and Chrome DevTools demo.", - content: GET_STARTED_SKILL, - }); - } -} - -async function ensureStarterCommands(workspaceRoot: string, preset: string): Promise { - await ensureDir(projectCommandsDir(workspaceRoot)); - await upsertCommand(workspaceRoot, { - name: "learn-files", - description: "Safe, practical file workflows", - template: "Show me how to interact with files in this workspace. Include safe examples for reading, summarizing, and editing.", - }); - await upsertCommand(workspaceRoot, { - name: "learn-skills", - description: "How skills work and how to create your own", - template: "Explain what skills are, how to use them, and how to create a new skill for this workspace.", - }); - await upsertCommand(workspaceRoot, { - name: "learn-plugins", - description: "What plugins are and how to install them", - template: "Explain what plugins are and how to install them in this workspace.", - }); - if (preset === "starter") { - await upsertCommand(workspaceRoot, { - name: "get-started", - description: "Get started", - template: "get started", - }); - } -} - -async function ensureOpencodeConfig(workspaceRoot: string, preset: string): Promise { - const path = opencodeConfigPath(workspaceRoot); - const { data } = await readJsoncFile>(path, { - $schema: "https://opencode.ai/config.json", - }); - const next: Record = data && typeof data === "object" && !Array.isArray(data) - ? { ...data } - : { $schema: "https://opencode.ai/config.json" }; - - if (typeof next.default_agent !== "string" || !next.default_agent.trim()) { - next.default_agent = "openwork"; - } - - const requiredPlugins = preset === "starter" || preset === "automation" - ? ["opencode-scheduler"] - : []; - if (requiredPlugins.length > 0) { - const currentPlugins = Array.isArray(next.plugin) - ? next.plugin.filter((value: unknown): value is string => typeof value === "string") - : typeof next.plugin === "string" - ? [next.plugin] - : []; - next.plugin = mergePlugins(currentPlugins, requiredPlugins); - } - - if (preset === "starter") { - const currentMcp = next.mcp && typeof next.mcp === "object" && !Array.isArray(next.mcp) - ? { ...(next.mcp as Record) } - : {}; - if (!("control-chrome" in currentMcp)) { - currentMcp["control-chrome"] = { - type: "local", - command: ["chrome-devtools-mcp"], - }; - } - next.mcp = currentMcp; - } - - await writeJsoncFile(path, next); -} - async function ensureWorkspaceOpenworkConfig(workspaceRoot: string, preset: string): Promise { const path = openworkConfigPath(workspaceRoot); if (await exists(path)) return; @@ -315,23 +49,44 @@ async function ensureWorkspaceOpenworkConfig(workspaceRoot: string, preset: stri preset, }, authorizedRoots: [workspaceRoot], - blueprint: buildDefaultWorkspaceBlueprint(preset), reload: null, }; await ensureDir(join(workspaceRoot, ".opencode")); await writeFile(path, JSON.stringify(config, null, 2) + "\n", "utf8"); } +async function ensureOpencodeConfig(workspaceRoot: string): Promise { + const path = opencodeConfigPath(workspaceRoot); + const { data } = await readJsoncFile>(path, { + $schema: "https://opencode.ai/config.json", + }); + const next: Record = data && typeof data === "object" && !Array.isArray(data) + ? { ...data } + : { $schema: "https://opencode.ai/config.json" }; + + if (typeof next.default_agent !== "string" || !next.default_agent.trim()) { + next.default_agent = "openwork"; + } + + await writeJsoncFile(path, next); +} + +async function ensureOpenworkAgent(workspaceRoot: string): Promise { + const agentsDir = join(workspaceRoot, ".opencode", "agents"); + const agentPath = join(agentsDir, "openwork.md"); + if (await exists(agentPath)) return; + await ensureDir(agentsDir); + await writeFile(agentPath, OPENWORK_AGENT.endsWith("\n") ? OPENWORK_AGENT : `${OPENWORK_AGENT}\n`, "utf8"); +} + export async function ensureWorkspaceFiles(workspaceRoot: string, presetInput: string): Promise { const preset = normalizePreset(presetInput); if (!workspaceRoot.trim()) { throw new ApiError(400, "invalid_workspace_path", "workspace path is required"); } await ensureDir(workspaceRoot); - await ensureStarterSkills(workspaceRoot, preset); + await ensureOpencodeConfig(workspaceRoot); await ensureOpenworkAgent(workspaceRoot); - await ensureStarterCommands(workspaceRoot, preset); - await ensureOpencodeConfig(workspaceRoot, preset); await ensureWorkspaceOpenworkConfig(workspaceRoot, preset); }