Move local workspace ownership into OpenWork server (#1107)

* feat(server): move local workspace ownership off Tauri

* docs: clarify architecture authority for agents

* fix: harden workspace reconnect and sidebar navigation

* feat(app): soften get-started copy in workspace onboarding

* fix(app): stabilize starter workspace onboarding

* fix(app): normalize local workspace paths
This commit is contained in:
ben
2026-03-22 17:37:51 -07:00
committed by GitHub
parent 3709ea33ec
commit ed4d026f1c
23 changed files with 1325 additions and 185 deletions

View File

@@ -38,7 +38,7 @@ User mental model:
* A worker is a remote runtime destination.
* Connecting to a worker happens through `Add worker` -> `Connect remote` using URL + token (or deep link).
Read INFRASTRUCTURE.md
Read `ARCHITECTURE.md` for runtime flow, server-vs-shell ownership, and architecture behavior. Read `INFRASTRUCTURE.md` for deployment and control-plane details.
## Why OpenWork Exists
@@ -126,12 +126,13 @@ Design principles for hot reload:
## Repository Guidance
* Use `VISION.md`, `PRINCIPLES.md`, `PRODUCT.md`, `ARCHITECTURE.md`, and `INFRASTRUCTURE.md` to understand the "why" and requirements so you can guide your decisions.
* Treat `ARCHITECTURE.md` as the authoritative system design source for runtime flow, server ownership, filesystem mutation policy, and agent/runtime boundaries. If those behaviors change, update `ARCHITECTURE.md` in the same task.
* Use `DESIGN-LANGUAGE.md` as the default visual reference for OpenWork app and landing work.
* For OpenWork session-surface details, also reference `packages/docs/orbita-layout-style.mdx`.
## Dev Debugging
* If you change `packages/server/src`, rebuild the OpenWork server binary (`pnpm --filter openwork-server build:bin`) because `openwork` (openwork-orchestrator) runs the compiled server, not the TS sources.
* If you change `apps/server/src`, rebuild the OpenWork server binary (`pnpm --filter openwork-server build:bin`) because `openwork` (openwork-orchestrator) runs the compiled server, not the TS sources.
## Local Structure
@@ -144,7 +145,7 @@ openwork/
ARCHITECTURE.md # Runtime modes and OpenCode integration
.gitignore # Ignores vendor/opencode, node_modules, etc.
.opencode/
packages/
apps/
app/
src/
public/
@@ -154,6 +155,9 @@ openwork/
desktop/
src-tauri/
package.json
server/
src/
package.json
```
## OpenCode SDK Usage
@@ -190,7 +194,7 @@ Key primitives to expose:
## Skill: SolidJS Patterns
When editing SolidJS UI (`packages/app/src/**/*.tsx`), consult:
When editing SolidJS UI (`apps/app/src/**/*.tsx`), consult:
* `.opencode/skills/solidjs-patterns/SKILL.md`
@@ -206,11 +210,11 @@ OpenWork releases are built by GitHub Actions (`Release App`). A release is trig
1. Ensure `main` is green and up to date.
2. Bump versions (keep these in sync):
* `packages/app/package.json` (`version`)
* `packages/desktop/package.json` (`version`)
* `packages/orchestrator/package.json` (`version`, publishes as `openwork-orchestrator`)
* `packages/desktop/src-tauri/tauri.conf.json` (`version`)
* `packages/desktop/src-tauri/Cargo.toml` (`version`)
* `apps/app/package.json` (`version`)
* `apps/desktop/package.json` (`version`)
* `apps/orchestrator/package.json` (`version`, publishes as `openwork-orchestrator`)
* `apps/desktop/src-tauri/tauri.conf.json` (`version`)
* `apps/desktop/src-tauri/Cargo.toml` (`version`)
You can bump all three non-interactively with:
@@ -244,11 +248,11 @@ Confirm the DMG assets are attached and versioned correctly.
This is usually covered by `Release App` when `publish_sidecars` + `publish_npm` are enabled. Use `.opencode/skills/openwork-orchestrator-npm-publish/SKILL.md` for manual recovery or one-off publishing.
1. Ensure the default branch is up to date and clean.
2. Bump `packages/orchestrator/package.json` (`version`).
2. Bump `apps/orchestrator/package.json` (`version`).
3. Commit the bump.
4. Build and upload sidecar assets for the same version tag:
* `pnpm --filter openwork-orchestrator build:sidecars`
* `gh release create openwork-orchestrator-vX.Y.Z packages/orchestrator/dist/sidecars/* --repo different-ai/openwork`
* `gh release create openwork-orchestrator-vX.Y.Z apps/orchestrator/dist/sidecars/* --repo different-ai/openwork`
5. Publish:
* `pnpm --filter openwork-orchestrator publish --access public`
6. Verify:

View File

@@ -30,6 +30,11 @@ Auto-detection can exist as a convenience, but should be tiered and explainable:
The readiness check should be a clear, single command (e.g. `docker info`) and the UI should show the exact error output when it fails.
## Minimal use of Tauri
We move most of the functionality to the openwork server which interfaces mostly with FS and proxies to opencode.
## Filesystem mutation policy
OpenWork should route filesystem mutations through the OpenWork server whenever possible.
@@ -47,6 +52,36 @@ Guidelines:
- If a feature cannot yet write through the OpenWork server, treat that as an architecture gap and close it before depending on direct local writes.
- Reads can fall back locally when necessary, but writes should be designed around the OpenWork server path.
## Agent authority map
When OpenWork is edited from `openwork-enterprise`, architecture and runtime behavior should be sourced from this document.
| Entry point | Role | Architecture authority |
| --- | --- | --- |
| `openwork-enterprise/AGENTS.md` | OpenWork Factory multi-repo orchestration | Defers OpenWork runtime flow, server-vs-shell ownership, and filesystem mutation behavior to `_repos/openwork/ARCHITECTURE.md`. |
| `openwork-enterprise/.opencode/agents/openwork-surgeon.md` | Surgical fix agent for `_repos/openwork` | Uses `_repos/openwork/ARCHITECTURE.md` as the runtime and architecture source of truth before changing product behavior. |
| `_repos/openwork/AGENTS.md` | Product vocabulary, audience, and repo-local development guidance | Refers to `ARCHITECTURE.md` for runtime flow, server ownership, and architectural boundaries. |
| Skills / commands / agents that mutate workspace state | Capability layer on top of the product runtime | Should assume the OpenWork server path is canonical for workspace creation, config writes, `.opencode/` mutation, and reload signaling. |
### Agent access to server-owned behavior
Agents, skills, and commands should model the following as OpenWork server behavior first:
- workspace creation and initialization
- writes to `.opencode/`, `opencode.json`, and `opencode.jsonc`
- OpenWork workspace config writes (`.opencode/openwork.json`)
- reload event generation after config or capability changes
- other filesystem-backed capability changes that must work across desktop host mode and remote clients
Tauri or other native shell behavior remains the fallback or shell boundary for:
- file and folder picking
- reveal/open-in-OS affordances
- updater and window management
- host-side process supervision and native runtime bootstrapping
If an agent needs one of the server-owned behaviors above and only a Tauri path exists, treat that as an architecture gap to close rather than a parallel capability surface to preserve.
## opencode primitives
how to pick the right extension abstraction for
@opencode

View File

@@ -3023,6 +3023,8 @@ export default function App() {
openworkServerSettings,
updateOpenworkServerSettings,
openworkServerClient,
openworkServerStatus,
openworkServerWorkspaceId,
onEngineStable: () => {},
engineRuntime,
developerMode,
@@ -3324,6 +3326,26 @@ export default function App() {
? allSessions.filter((session) => normalizeDirectoryPath(session.directory) === activeWorkspaceRoot)
: allSessions;
const sorted = sortSessionsByActivity(scopedSessions);
if (developerMode()) {
console.log("[sidebar-sync] workspace session scope", {
wsId,
status,
activeWorkspace,
activeWorkspaceRoot,
allSessions: allSessions.map((session) => ({
id: session.id,
title: session.title,
directory: session.directory,
parentID: session.parentID,
})),
scopedSessions: scopedSessions.map((session) => ({
id: session.id,
title: session.title,
directory: session.directory,
parentID: session.parentID,
})),
});
}
const rootItems: SidebarSessionItem[] = sorted.map((s) => ({
id: s.id,
title: s.title,
@@ -3421,6 +3443,22 @@ export default function App() {
}
return dedupedWorkspaces.map((workspace) => {
const groupSessions = sessionsById[workspace.id] ?? [];
if (developerMode()) {
console.log("[sidebar-groups] workspace group", {
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceType: workspace.workspaceType,
workspacePath: workspace.path,
workspaceDirectory: workspace.directory,
sessionCount: groupSessions.length,
sessions: groupSessions.map((session) => ({
id: session.id,
title: session.title,
directory: session.directory,
parentID: session.parentID,
})),
});
}
return {
workspace,
sessions: groupSessions,
@@ -3518,7 +3556,7 @@ export default function App() {
if (cancelled) return;
const items = Array.isArray(response.items) ? response.items : [];
const match = items.find((entry) => normalizeDirectoryPath(entry.path) === root);
setOpenworkServerWorkspaceId(match?.id ?? response.activeId ?? null);
setOpenworkServerWorkspaceId(match?.id ?? null);
} catch {
if (!cancelled) setOpenworkServerWorkspaceId(null);
}
@@ -3608,7 +3646,7 @@ export default function App() {
};
const findSharedBundleImportWorkspaceId = (
items: Array<{ id: string; path?: string; directory?: string; opencode?: { directory?: string } }>,
items: Array<{ id: string; path?: string | null; directory?: string | null; opencode?: { directory?: string | null } }>,
target?: SharedBundleImportTarget,
) => {
const explicitId = target?.workspaceId?.trim() ?? "";
@@ -4903,8 +4941,6 @@ export default function App() {
// Scheduler helpers - must be defined after workspaceStore
const resolveOpenworkScheduler = () => {
const isRemoteWorkspace = workspaceStore.activeWorkspaceDisplay().workspaceType === "remote";
if (!isRemoteWorkspace) return null;
const client = openworkServerClient();
const workspaceId = openworkServerWorkspaceId();
if (openworkServerStatus() !== "connected" || !client || !workspaceId) return null;
@@ -4912,7 +4948,7 @@ export default function App() {
};
const scheduledJobsSource = createMemo<"local" | "remote">(() => {
return workspaceStore.activeWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local";
return resolveOpenworkScheduler() ? "remote" : "local";
});
const scheduledJobsSourceReady = createMemo(() => {
@@ -5620,6 +5656,25 @@ export default function App() {
}
}
const readMcpConfigFile = async (scope: "project" | "global") => {
const projectDir = workspaceProjectDir().trim();
const openworkClient = openworkServerClient();
const openworkWorkspaceId = openworkServerWorkspaceId();
const canUseOpenworkServer =
openworkServerStatus() === "connected" &&
openworkClient &&
openworkWorkspaceId &&
resolvedOpenworkCapabilities()?.config?.read;
if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) {
return openworkClient.readOpencodeConfigFile(openworkWorkspaceId, scope);
}
if (!isTauriRuntime()) {
return null;
}
return readOpencodeConfig(scope, projectDir);
};
async function connectMcp(entry: (typeof MCP_QUICK_CONNECT)[number]) {
const startedAt = perfNow();
const isRemoteWorkspace =
@@ -7109,6 +7164,10 @@ export default function App() {
setTab("settings");
setView("dashboard");
},
onOpenAdvancedSettings: () => {
setTab("config");
setView("dashboard");
},
themeMode: themeMode(),
setThemeMode,
});
@@ -7217,6 +7276,8 @@ export default function App() {
testWorkspaceConnection: workspaceStore.testWorkspaceConnection,
recoverWorkspace: workspaceStore.recoverWorkspace,
openCreateWorkspace: () => workspaceStore.setCreateWorkspaceOpen(true),
getStartedWorkspace: workspaceStore.quickStartWorkspaceFlow,
pickFolderWorkspace: workspaceStore.createWorkspaceFromPickedFolder,
openCreateRemoteWorkspace: () => workspaceStore.setCreateRemoteWorkspaceOpen(true),
connectRemoteWorkspace: workspaceStore.createRemoteWorkspaceFlow,
importWorkspaceConfig: workspaceStore.importWorkspaceConfig,
@@ -7391,6 +7452,7 @@ export default function App() {
mcpConnectingName: mcpConnectingName(),
selectedMcp: selectedMcp(),
setSelectedMcp,
readConfigFile: readMcpConfigFile,
quickConnect: MCP_QUICK_CONNECT,
connectMcp,
authorizeMcp,
@@ -7449,6 +7511,8 @@ export default function App() {
editWorkspaceConnection: openWorkspaceConnectionSettings,
forgetWorkspace: workspaceStore.forgetWorkspace,
openCreateWorkspace: () => workspaceStore.setCreateWorkspaceOpen(true),
getStartedWorkspace: workspaceStore.quickStartWorkspaceFlow,
pickFolderWorkspace: workspaceStore.createWorkspaceFromPickedFolder,
openCreateRemoteWorkspace: () => workspaceStore.setCreateRemoteWorkspaceOpen(true),
importWorkspaceConfig: workspaceStore.importWorkspaceConfig,
importingWorkspaceConfig: workspaceStore.importingWorkspaceConfig(),

View File

@@ -4,6 +4,7 @@ import {
ChevronLeft,
ChevronRight,
History,
MessageCircle,
Settings,
SlidersHorizontal,
X,
@@ -30,6 +31,7 @@ type Props = {
onOpenAutomations: () => void;
onOpenSkills: () => void;
onOpenExtensions: () => void;
onOpenMessaging: () => void;
onOpenAdvanced: () => void;
onOpenSettings: () => void;
onInboxToast?: (message: string) => void;
@@ -117,6 +119,12 @@ export default function WorkspaceRightSidebar(props: Props) {
showSelection() && (props.tab === "mcp" || props.tab === "plugins"),
props.onOpenExtensions,
)}
{sidebarButton(
"Messaging",
<MessageCircle size={18} />,
showSelection() && props.tab === "identities",
props.onOpenMessaging,
)}
<Show when={props.developerMode}>
{sidebarButton(
"Advanced",

View File

@@ -686,9 +686,24 @@ export function createSessionStore(options: {
// string equality against the stored session.directory.
const queryDirectory = normalizeDirectoryQueryPath(scopeRoot) || undefined;
sessionDebug("sessions:load:request", {
scopeRoot: scopeRoot ?? null,
queryDirectory: queryDirectory ?? null,
activeWorkspaceRoot: options.activeWorkspaceRoot?.() ?? null,
});
const start = Date.now();
sessionDebug("sessions:load:start", { scopeRoot: scopeRoot ?? null, queryDirectory: queryDirectory ?? null });
const list = unwrap(await c.session.list({ directory: queryDirectory, roots: true }));
sessionDebug("sessions:load:response", {
count: list.length,
sessions: list.map((session) => ({
id: session.id,
title: session.title,
directory: session.directory,
parentID: session.parentID,
})),
});
sessionDebug("sessions:load:raw", { count: list.length, ms: Date.now() - start });
// Defensive client-side filter in case the server returns sessions spanning
@@ -697,6 +712,16 @@ export function createSessionStore(options: {
const filtered = root
? list.filter((session) => normalizeDirectoryPath(session.directory) === root)
: list;
sessionDebug("sessions:load:filtered-list", {
root: root || null,
count: filtered.length,
sessions: filtered.map((session) => ({
id: session.id,
title: session.title,
directory: session.directory,
parentID: session.parentID,
})),
});
sessionDebug("sessions:load:filtered", { root: root || null, count: filtered.length });
rememberSessions(filtered);
setStore("sessions", reconcile(sortSessionsByActivity(filtered), { key: "id" }));

View File

@@ -28,6 +28,7 @@ import {
OpenworkServerError,
type OpenworkServerClient,
type OpenworkServerSettings,
type OpenworkServerStatus,
type OpenworkWorkspaceInfo,
} from "../lib/openwork-server";
import { downloadDir, homeDir } from "@tauri-apps/api/path";
@@ -145,6 +146,8 @@ export function createWorkspaceStore(options: {
openworkServerSettings: () => OpenworkServerSettings;
updateOpenworkServerSettings: (next: OpenworkServerSettings) => void;
openworkServerClient?: () => OpenworkServerClient | null;
openworkServerStatus?: () => OpenworkServerStatus;
openworkServerWorkspaceId?: () => string | null;
setOpencodeConnectStatus?: (status: OpencodeConnectStatus | null) => void;
onEngineStable?: () => void;
engineRuntime?: () => EngineRuntime;
@@ -189,6 +192,9 @@ export function createWorkspaceStore(options: {
const LONG_BOOT_CONNECT_REASONS = new Set(["host-start", "bootstrap-local"]);
const INITIAL_WORKSPACE_SETUP_COMPLETE_KEY = "openwork.initialWorkspaceSetupComplete";
const LEGACY_ONBOARDING_COMPLETE_KEY = "openwork.onboardingComplete";
const STARTER_BOOTSTRAP_STATE_KEY = "openwork.starterBootstrapState";
const STARTER_BOOTSTRAP_FOLDER_NAME = "OpenWork";
const STARTER_BOOTSTRAP_WORKSPACE_NAME = "starter";
const DB_MIGRATE_UNSUPPORTED_PATTERNS = [
/unknown(?:\s+sub)?command\s+['"`]?db['"`]?/i,
/unrecognized(?:\s+sub)?command\s+['"`]?db['"`]?/i,
@@ -317,17 +323,59 @@ export function createWorkspaceStore(options: {
}
};
type StarterBootstrapState = "not_started" | "in_progress" | "completed" | "failed" | "skipped";
const readStarterBootstrapState = (): StarterBootstrapState => {
if (typeof window === "undefined") return "not_started";
try {
const raw = window.localStorage.getItem(STARTER_BOOTSTRAP_STATE_KEY);
if (raw === "in_progress" || raw === "completed" || raw === "failed" || raw === "skipped") {
return raw;
}
return "not_started";
} catch {
return "not_started";
}
};
const persistStarterBootstrapState = (next: StarterBootstrapState) => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STARTER_BOOTSTRAP_STATE_KEY, next);
} catch {
// ignore
}
};
const [projectDir, setProjectDir] = createSignal("");
const [workspaces, setWorkspaces] = createSignal<WorkspaceInfo[]>([]);
const [activeWorkspaceId, setActiveWorkspaceId] = createSignal<string>("");
const [initialWorkspaceSetupComplete, setInitialWorkspaceSetupComplete] = createSignal(
readInitialWorkspaceSetupComplete(),
);
const [starterBootstrapState, setStarterBootstrapState] = createSignal<StarterBootstrapState>(
readStarterBootstrapState(),
);
const syncActiveWorkspaceId = (id: string) => {
setActiveWorkspaceId(id);
};
const applyServerLocalWorkspaces = (nextLocals: WorkspaceInfo[], nextActiveId: string | null | undefined) => {
const remotes = workspaces().filter((workspace) => workspace.workspaceType === "remote");
const merged = [...nextLocals, ...remotes];
setWorkspaces(merged);
const currentActiveId = activeWorkspaceId();
const fallbackActiveId = merged.some((workspace) => workspace.id === currentActiveId)
? currentActiveId
: merged[0]?.id ?? "";
const resolvedActiveId = nextActiveId?.trim() || fallbackActiveId;
if (resolvedActiveId) {
syncActiveWorkspaceId(resolvedActiveId);
}
};
const [authorizedDirs, setAuthorizedDirs] = createSignal<string[]>([]);
const [newAuthorizedDir, setNewAuthorizedDir] = createSignal("");
@@ -348,6 +396,11 @@ export function createWorkspaceStore(options: {
const firstRunWorkspaceSetup = createMemo(
() => isTauriRuntime() && !initialWorkspaceSetupComplete() && workspaces().length === 0,
);
const setPersistedStarterBootstrapState = (next: StarterBootstrapState) => {
setStarterBootstrapState(next);
persistStarterBootstrapState(next);
};
const activeWorkspaceDisplay = createMemo<WorkspaceDisplay>(() => {
const ws = activeWorkspaceInfo();
if (!ws) {
@@ -554,18 +607,55 @@ export function createWorkspaceStore(options: {
return resolved;
};
const activateOpenworkHostWorkspace = async (workspacePath: string) => {
const resolveConnectedOpenworkServer = () => {
const client = options.openworkServerClient?.();
if (!client) return;
if (!client) return null;
if (options.openworkServerStatus?.() !== "connected") return null;
return client;
};
const resolveActiveOpenworkWorkspace = () => {
const client = resolveConnectedOpenworkServer();
const workspaceId = options.openworkServerWorkspaceId?.()?.trim() ?? "";
if (!client || !workspaceId) return null;
return { client, workspaceId };
};
const findOpenworkWorkspaceByPath = async (workspacePath: string) => {
const client = resolveConnectedOpenworkServer();
const targetPath = normalizeDirectoryPath(workspacePath);
if (!targetPath) return;
if (!client || !targetPath) return null;
const response = await client.listWorkspaces();
const items = Array.isArray(response.items) ? response.items : [];
const match = items.find((entry) => normalizeDirectoryPath(entry.path) === targetPath);
if (!match?.id) return null;
return { client, workspaceId: match.id, response };
};
const loadWorkspaceConfigFromOpenworkServer = async (workspacePath: string): Promise<WorkspaceOpenworkConfig | null> => {
const resolved = await findOpenworkWorkspaceByPath(workspacePath);
if (!resolved) return null;
if (resolved.response.activeId !== resolved.workspaceId) {
await resolved.client.activateWorkspace(resolved.workspaceId);
}
const config = await resolved.client.getConfig(resolved.workspaceId);
return (config.openwork as WorkspaceOpenworkConfig | null | undefined) ?? null;
};
const persistWorkspaceConfigToOpenworkServer = async (config: WorkspaceOpenworkConfig): Promise<boolean> => {
const active = resolveActiveOpenworkWorkspace();
if (!active) return false;
await active.client.patchConfig(active.workspaceId, { openwork: config as Record<string, unknown> });
return true;
};
const activateOpenworkHostWorkspace = async (workspacePath: string) => {
const resolved = await findOpenworkWorkspaceByPath(workspacePath);
if (!resolved) return;
try {
const response = await client.listWorkspaces();
const items = Array.isArray(response.items) ? response.items : [];
const match = items.find((entry) => normalizeDirectoryPath(entry.path) === targetPath);
if (!match?.id) return;
if (response.activeId === match.id) return;
await client.activateWorkspace(match.id);
if (resolved.response.activeId === resolved.workspaceId) return;
await resolved.client.activateWorkspace(resolved.workspaceId);
} catch {
// ignore
}
@@ -994,7 +1084,8 @@ export function createWorkspaceStore(options: {
} else {
setWorkspaceConfigLoaded(false);
try {
const cfg = await workspaceOpenworkRead({ workspacePath: next.path });
const cfg = await loadWorkspaceConfigFromOpenworkServer(next.path)
?? await workspaceOpenworkRead({ workspacePath: next.path });
setWorkspaceConfig(cfg);
setWorkspaceConfigLoaded(true);
@@ -1012,6 +1103,9 @@ export function createWorkspaceStore(options: {
}
try {
if (!isRemote) {
await activateOpenworkHostWorkspace(next.path);
}
await workspaceSetActive(id);
} catch {
// ignore
@@ -1427,6 +1521,13 @@ export function createWorkspaceStore(options: {
const openEmptySession = async (scopeRoot?: string) => {
const root = (scopeRoot ?? activeWorkspaceRoot().trim()).trim();
wsDebug("open-empty-session:start", {
scopeRoot: scopeRoot ?? null,
resolvedRoot: root || null,
activeWorkspaceId: activeWorkspaceId(),
activeWorkspace: activeWorkspaceInfo(),
hasClient: Boolean(options.client()),
});
if (options.client()) {
try {
@@ -1488,22 +1589,34 @@ export function createWorkspaceStore(options: {
return false;
}
const name = resolvedFolder.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "Worker";
const ws = await workspaceCreate({ folderPath: resolvedFolder, name, preset });
setWorkspaces(ws.workspaces);
syncActiveWorkspaceId(ws.activeId);
const name = deriveWorkspaceName(resolvedFolder, preset);
const openworkServer = resolveConnectedOpenworkServer();
const ws = openworkServer
? await openworkServer.createLocalWorkspace({ folderPath: resolvedFolder, name, preset })
: await workspaceCreate({ folderPath: resolvedFolder, name, preset });
if (openworkServer && isTauriRuntime()) {
try {
await workspaceCreate({ folderPath: resolvedFolder, name, preset });
} catch {
// keep the server result as the source of truth for this run
}
}
applyServerLocalWorkspaces(ws.workspaces, ws.activeId);
if (ws.activeId) {
updateWorkspaceConnectionState(ws.activeId, { status: "connected", message: null });
}
setCreateWorkspaceOpen(false);
markOnboardingComplete();
const opened = await activateFreshLocalWorkspace(ws.activeId ?? null, resolvedFolder);
if (!opened) {
return false;
}
markOnboardingComplete();
return true;
} catch (e) {
const message = e instanceof Error ? e.message : safeStringify(e);
@@ -1599,24 +1712,45 @@ export function createWorkspaceStore(options: {
return false;
}
const name = resolvedFolder.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "Worker";
const name = deriveWorkspaceName(resolvedFolder, preset);
setSandboxStep("workspace", { status: "active", detail: name });
pushSandboxCreateLog(`Worker: ${resolvedFolder}`);
// Ensure the workspace folder has baseline OpenWork/OpenCode files.
const created = await workspaceCreate({ folderPath: resolvedFolder, name, preset });
setWorkspaces(created.workspaces);
syncActiveWorkspaceId(created.activeId);
const openworkServer = resolveConnectedOpenworkServer();
const created = openworkServer
? await openworkServer.createLocalWorkspace({ folderPath: resolvedFolder, name, preset })
: await workspaceCreate({ folderPath: resolvedFolder, name, preset });
if (openworkServer && isTauriRuntime()) {
try {
await workspaceCreate({ folderPath: resolvedFolder, name, preset });
} catch {
// ignore desktop mirror failures here
}
}
applyServerLocalWorkspaces(created.workspaces, created.activeId);
setSandboxStep("workspace", { status: "done", detail: null });
// Remove the local workspace entry to avoid duplicate Local+Remote rows.
const localId = created.activeId;
if (localId) {
pushSandboxCreateLog("Removing local worker row (will re-add as remote sandbox)...");
const forgotten = await workspaceForget(localId);
setWorkspaces(forgotten.workspaces);
syncActiveWorkspaceId(forgotten.activeId);
const activeLocalWorkspace = openworkServer ? await findOpenworkWorkspaceByPath(resolvedFolder) : null;
const forgotten = await (activeLocalWorkspace
? (() => activeLocalWorkspace.client.deleteWorkspace(activeLocalWorkspace.workspaceId).then((response) => ({
activeId: response.activeId ?? "",
workspaces: response.workspaces ?? response.items,
})))()
: workspaceForget(localId));
if (activeLocalWorkspace && isTauriRuntime()) {
try {
await workspaceForget(localId);
} catch {
// ignore desktop mirror failures here
}
}
applyServerLocalWorkspaces(forgotten.workspaces, forgotten.activeId);
}
setSandboxStep("sandbox", { status: "active", detail: null });
@@ -2130,15 +2264,38 @@ export function createWorkspaceStore(options: {
const id = workspaceId.trim();
if (!id) return;
const workspace = workspaces().find((entry) => entry.id === id) ?? null;
console.log("[workspace] forget", { id });
try {
const previousActive = activeWorkspaceId();
const ws = await workspaceForget(id);
setWorkspaces(ws.workspaces);
const openworkWorkspace = workspace?.workspaceType === "local" ? await findOpenworkWorkspaceByPath(workspace.path) : null;
const ws = openworkWorkspace
? await openworkWorkspace.client.deleteWorkspace(openworkWorkspace.workspaceId).then((response) => ({
activeId: response.activeId ?? "",
workspaces: response.workspaces ?? response.items,
}))
: await workspaceForget(id);
if (openworkWorkspace && isTauriRuntime()) {
try {
await workspaceForget(id);
} catch {
// ignore desktop mirror failures here
}
}
if (openworkWorkspace) {
applyServerLocalWorkspaces(ws.workspaces, ws.activeId);
} else {
setWorkspaces(ws.workspaces);
}
clearWorkspaceConnectionState(id);
syncActiveWorkspaceId(ws.activeId);
if (!openworkWorkspace) {
syncActiveWorkspaceId(ws.activeId);
}
const active = ws.workspaces.find((w) => w.id === ws.activeId) ?? null;
if (active) {
@@ -2335,6 +2492,74 @@ export function createWorkspaceStore(options: {
}
}
function joinNativePath(base: string, leaf: string) {
const trimmedBase = base.replace(/[\\/]+$/, "");
if (!trimmedBase) return leaf;
const separator = trimmedBase.includes("\\") ? "\\" : "/";
return `${trimmedBase}${separator}${leaf}`;
}
function deriveWorkspaceName(folderPath: string, preset: WorkspacePreset) {
const leaf = folderPath.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "Worker";
if (preset === "starter" && leaf.trim().toLowerCase() === STARTER_BOOTSTRAP_WORKSPACE_NAME) {
return "Starter";
}
return leaf;
}
async function resolveStarterBootstrapFolder() {
const base = (await homeDir()).replace(/[\\/]+$/, "");
return joinNativePath(joinNativePath(base, STARTER_BOOTSTRAP_FOLDER_NAME), STARTER_BOOTSTRAP_WORKSPACE_NAME);
}
async function quickStartWorkspaceFlow() {
if (!isTauriRuntime()) {
options.setError(t("app.error.tauri_required", currentLocale()));
return false;
}
try {
return await createWorkspaceFlow("starter", await resolveStarterBootstrapFolder());
} catch (e) {
const message = e instanceof Error ? e.message : safeStringify(e);
options.setError(addOpencodeCacheHint(message));
return false;
}
}
async function autoBootstrapStarterWorkspace() {
if (!isTauriRuntime()) return false;
options.setStartupPreference("local");
options.setOnboardingStep("bootstrap");
setPersistedStarterBootstrapState("in_progress");
options.setError(null);
try {
const ok = await createWorkspaceFlow("starter", await resolveStarterBootstrapFolder());
if (!ok) {
setPersistedStarterBootstrapState("failed");
options.setOnboardingStep("local");
return false;
}
setPersistedStarterBootstrapState("completed");
return true;
} catch (e) {
const message = e instanceof Error ? e.message : safeStringify(e);
options.setError(addOpencodeCacheHint(message));
setPersistedStarterBootstrapState("failed");
options.setOnboardingStep("local");
return false;
}
}
async function createWorkspaceFromPickedFolder() {
const folder = await pickWorkspaceFolder();
if (!folder) return false;
return createWorkspaceFlow("minimal", folder);
}
async function exportWorkspaceConfig(workspaceId?: string) {
if (exportingWorkspaceConfig()) return;
if (!isTauriRuntime()) {
@@ -2661,6 +2886,32 @@ export function createWorkspaceStore(options: {
const nextDisplayName = displayName?.trim() || null;
options.setError(null);
const openworkWorkspace = workspace.workspaceType === "local"
? await findOpenworkWorkspaceByPath(workspace.path)
: null;
if (openworkWorkspace) {
try {
const ws = await openworkWorkspace.client.updateWorkspaceDisplayName(openworkWorkspace.workspaceId, nextDisplayName);
if (isTauriRuntime()) {
try {
await workspaceUpdateDisplayName({ workspaceId: id, displayName: nextDisplayName });
} catch {
// ignore desktop mirror failures here
}
}
applyServerLocalWorkspaces(ws.workspaces, ws.activeId);
if (ws.activeId) {
updateWorkspaceConnectionState(ws.activeId, { status: "connected", message: null });
}
return true;
} catch (e) {
const message = e instanceof Error ? e.message : safeStringify(e);
options.setError(addOpencodeCacheHint(message));
return false;
}
}
if (isTauriRuntime()) {
try {
const ws = await workspaceUpdateDisplayName({ workspaceId: id, displayName: nextDisplayName });
@@ -2912,7 +3163,10 @@ export function createWorkspaceStore(options: {
reload: existing?.reload ?? null,
};
await workspaceOpenworkWrite({ workspacePath: root, config: cfg });
const persistedViaServer = await persistWorkspaceConfigToOpenworkServer(cfg).catch(() => false);
if (!persistedViaServer) {
await workspaceOpenworkWrite({ workspacePath: root, config: cfg });
}
setWorkspaceConfig(cfg);
}
@@ -2933,7 +3187,10 @@ export function createWorkspaceStore(options: {
},
};
await workspaceOpenworkWrite({ workspacePath: root, config: cfg });
const persistedViaServer = await persistWorkspaceConfigToOpenworkServer(cfg).catch(() => false);
if (!persistedViaServer) {
await workspaceOpenworkWrite({ workspacePath: root, config: cfg });
}
setWorkspaceConfig(cfg);
}
@@ -3000,7 +3257,9 @@ export function createWorkspaceStore(options: {
async function bootstrapOnboarding() {
const startupPref = readStartupPreference();
const onboardingComplete = readInitialWorkspaceSetupComplete();
const persistedBootstrapState = readStarterBootstrapState();
setInitialWorkspaceSetupComplete(onboardingComplete);
setStarterBootstrapState(persistedBootstrapState);
if (isTauriRuntime()) {
try {
@@ -3012,6 +3271,14 @@ export function createWorkspaceStore(options: {
}
}
if (isTauriRuntime() && persistedBootstrapState === "in_progress") {
if (workspaces().length > 0) {
setPersistedStarterBootstrapState("completed");
} else {
setPersistedStarterBootstrapState("failed");
}
}
await refreshEngine();
await refreshEngineDoctor();
@@ -3100,6 +3367,11 @@ export function createWorkspaceStore(options: {
}
if (firstRunWorkspaceSetup()) {
if (starterBootstrapState() === "not_started") {
await autoBootstrapStarterWorkspace();
return;
}
options.setStartupPreference("local");
options.setOnboardingStep("local");
return;
@@ -3230,6 +3502,8 @@ export function createWorkspaceStore(options: {
testWorkspaceConnection,
connectToServer,
createWorkspaceFlow,
quickStartWorkspaceFlow,
createWorkspaceFromPickedFolder,
createSandboxFlow,
createRemoteWorkspaceFlow,
updateRemoteWorkspaceFlow,

View File

@@ -1,6 +1,6 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { isTauriRuntime } from "../utils";
import type { ScheduledJob } from "./tauri";
import type { ExecResult, OpencodeConfigFile, ScheduledJob, WorkspaceInfo, WorkspaceList } from "./tauri";
export type OpenworkServerCapabilities = {
skills: { read: boolean; write: boolean; source: "openwork" | "opencode" };
@@ -88,13 +88,7 @@ export type OpenworkServerSettings = {
token?: string;
};
export type OpenworkWorkspaceInfo = {
id: string;
name: string;
path: string;
workspaceType: "local" | "remote";
baseUrl?: string;
directory?: string;
export type OpenworkWorkspaceInfo = WorkspaceInfo & {
opencode?: {
baseUrl?: string;
directory?: string;
@@ -105,6 +99,7 @@ export type OpenworkWorkspaceInfo = {
export type OpenworkWorkspaceList = {
items: OpenworkWorkspaceInfo[];
workspaces?: WorkspaceInfo[];
activeId?: string | null;
};
@@ -1216,6 +1211,22 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
opencodeRouterSlackIdentities: () =>
requestJsonRaw<OpenworkOpenCodeRouterSlackIdentitiesResult>(baseUrl, "/opencode-router/identities/slack", { token, hostToken, timeoutMs: timeouts.opencodeRouter }),
listWorkspaces: () => requestJson<OpenworkWorkspaceList>(baseUrl, "/workspaces", { token, hostToken, timeoutMs: timeouts.listWorkspaces }),
createLocalWorkspace: (payload: { folderPath: string; name: string; preset: string }) =>
requestJson<WorkspaceList>(baseUrl, "/workspaces/local", {
token,
hostToken,
method: "POST",
body: payload,
timeoutMs: timeouts.activateWorkspace,
}),
updateWorkspaceDisplayName: (workspaceId: string, displayName: string | null) =>
requestJson<WorkspaceList>(baseUrl, `/workspaces/${encodeURIComponent(workspaceId)}/display-name`, {
token,
hostToken,
method: "PATCH",
body: { displayName },
timeoutMs: timeouts.activateWorkspace,
}),
activateWorkspace: (workspaceId: string) =>
requestJson<{ activeId: string; workspace: OpenworkWorkspaceInfo }>(
baseUrl,
@@ -1223,7 +1234,7 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
{ token, hostToken, method: "POST", timeoutMs: timeouts.activateWorkspace },
),
deleteWorkspace: (workspaceId: string) =>
requestJson<{ ok: boolean; deleted: boolean; persisted: boolean; activeId: string | null; items: OpenworkWorkspaceInfo[] }>(
requestJson<{ ok: boolean; deleted: boolean; persisted: boolean; activeId: string | null; items: OpenworkWorkspaceInfo[]; workspaces?: WorkspaceInfo[] }>(
baseUrl,
`/workspaces/${encodeURIComponent(workspaceId)}`,
{ token, hostToken, method: "DELETE", timeoutMs: timeouts.deleteWorkspace },
@@ -1474,6 +1485,20 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
method: "PATCH",
body: payload,
}),
readOpencodeConfigFile: (workspaceId: string, scope: "project" | "global" = "project") => {
const query = `?scope=${scope}`;
return requestJson<OpencodeConfigFile>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/opencode-config${query}`, {
token,
hostToken,
});
},
writeOpencodeConfigFile: (workspaceId: string, scope: "project" | "global", content: string) =>
requestJson<ExecResult>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/opencode-config`, {
token,
hostToken,
method: "POST",
body: { scope, content },
}),
listReloadEvents: (workspaceId: string, options?: { since?: number }) => {
const query = typeof options?.since === "number" ? `?since=${options.since}` : "";
return requestJson<{ items: OpenworkReloadEvent[]; cursor?: number }>(

View File

@@ -1628,6 +1628,7 @@ export default function DashboardView(props: DashboardViewProps) {
onOpenAutomations={() => props.setTab("scheduled")}
onOpenSkills={() => props.setTab("skills")}
onOpenExtensions={() => props.setTab("mcp")}
onOpenMessaging={() => props.setTab("identities")}
onOpenAdvanced={openConfig}
onOpenSettings={() => openSettings("general")}
/>
@@ -1652,6 +1653,7 @@ export default function DashboardView(props: DashboardViewProps) {
onOpenAutomations={() => props.setTab("scheduled")}
onOpenSkills={() => props.setTab("skills")}
onOpenExtensions={() => props.setTab("mcp")}
onOpenMessaging={() => props.setTab("identities")}
onOpenAdvanced={openConfig}
onOpenSettings={() => openSettings("general")}
/>

View File

@@ -33,6 +33,7 @@ export type McpViewProps = {
busy: boolean;
activeWorkspaceRoot: string;
isRemoteWorkspace: boolean;
readConfigFile?: (scope: "project" | "global") => Promise<OpencodeConfigFile | null>;
showHeader?: boolean;
mcpServers: McpServerEntry[];
mcpStatus: string | null;
@@ -155,8 +156,9 @@ export default function McpView(props: McpViewProps) {
createEffect(() => {
const root = props.activeWorkspaceRoot.trim();
const nextId = (configRequestId += 1);
const readConfig = props.readConfigFile;
if (!isTauriRuntime()) {
if (!readConfig && !isTauriRuntime()) {
setProjectConfig(null);
setGlobalConfig(null);
setConfigError(null);
@@ -167,8 +169,10 @@ export default function McpView(props: McpViewProps) {
try {
setConfigError(null);
const [project, global] = await Promise.all([
root ? readOpencodeConfig("project", root) : Promise.resolve(null),
readOpencodeConfig("global", root),
root
? (readConfig ? readConfig("project") : readOpencodeConfig("project", root))
: Promise.resolve(null),
readConfig ? readConfig("global") : readOpencodeConfig("global", root),
]);
if (nextId !== configRequestId) return;
setProjectConfig(project);
@@ -207,7 +211,12 @@ export default function McpView(props: McpViewProps) {
setRevealBusy(true);
setConfigError(null);
try {
const resolved = await readOpencodeConfig(configScope(), root);
const resolved = props.readConfigFile
? await props.readConfigFile(configScope())
: await readOpencodeConfig(configScope(), root);
if (!resolved) {
throw new Error(tr("mcp.config_load_failed"));
}
const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener");
if (isWindowsPlatform()) {
await openPath(resolved.path);

View File

@@ -1,7 +1,7 @@
import { For, Match, Show, Switch, createEffect, createSignal, onCleanup } from "solid-js";
import type { OnboardingStep, StartupPreference } from "../types";
import type { WorkspaceInfo } from "../lib/tauri";
import { CheckCircle2, ChevronDown, Circle, Globe } from "lucide-solid";
import { CheckCircle2, ChevronDown, Circle, Globe, HardDrive } from "lucide-solid";
import Button from "../components/button";
import OnboardingWorkspaceSelector from "../components/onboarding-workspace-selector";
@@ -63,6 +63,7 @@ export type OnboardingViewProps = {
onInstallEngine: () => void;
onShowSearchNotes: () => void;
onOpenSettings: () => void;
onOpenAdvancedSettings: () => void;
themeMode: "light" | "dark" | "system";
setThemeMode: (value: "light" | "dark" | "system") => void;
};
@@ -75,7 +76,7 @@ export default function OnboardingView(props: OnboardingViewProps) {
createEffect(() => {
if (typeof window === "undefined") return;
if (props.onboardingStep !== "connecting") {
if (props.onboardingStep !== "connecting" && props.onboardingStep !== "bootstrap") {
setConnectingFallbackVisible(false);
return;
}
@@ -109,43 +110,62 @@ export default function OnboardingView(props: OnboardingViewProps) {
return (
<Switch>
<Match when={props.onboardingStep === "connecting"}>
<Match when={props.onboardingStep === "connecting" || props.onboardingStep === "bootstrap"}>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-1 text-gray-12 p-6 relative overflow-hidden">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-gray-2 via-gray-1 to-gray-1 opacity-50" />
<div class="z-10 flex flex-col items-center gap-6">
<div class="relative">
<OpenWorkLogo size={40} />
</div>
<div class="text-center">
<h2 class="text-xl font-medium mb-2">
{props.startupPreference === "local"
? translate("onboarding.starting_host")
: translate("onboarding.searching_host")}
</h2>
<p class="text-gray-10 text-sm">
{props.startupPreference === "local"
? translate("onboarding.getting_ready")
: translate("onboarding.verifying")}
</p>
<div class="z-10 w-full max-w-lg rounded-[28px] border border-dls-border bg-dls-surface/95 p-8 shadow-[var(--dls-shell-shadow)]">
<div class="flex flex-col items-center gap-6 text-center">
<div class="flex h-16 w-16 items-center justify-center rounded-[22px] border border-dls-border bg-dls-sidebar shadow-[var(--dls-card-shadow)]">
<Show when={props.onboardingStep === "bootstrap"} fallback={<OpenWorkLogo size={36} />}>
<HardDrive size={24} class="text-gray-11" />
</Show>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-dls-secondary">
{props.onboardingStep === "bootstrap" ? "First-time setup" : "Connecting"}
</div>
<h2 class="mt-3 text-2xl font-semibold tracking-tight text-gray-12">
{props.onboardingStep === "bootstrap"
? "Getting your starter workspace ready"
: props.startupPreference === "local"
? translate("onboarding.starting_host")
: translate("onboarding.searching_host")}
</h2>
<p class="mt-2 text-sm leading-6 text-dls-secondary">
{props.onboardingStep === "bootstrap"
? "Were preparing everything so you can start in one click. This only happens the first time."
: props.startupPreference === "local"
? translate("onboarding.getting_ready")
: translate("onboarding.verifying")}
</p>
</div>
<div class="w-full rounded-[22px] border border-dls-border bg-dls-sidebar px-5 py-4 text-left">
<div class="flex items-center gap-3 text-sm font-medium text-gray-12">
<div class="h-2.5 w-2.5 rounded-full bg-dls-accent animate-pulse" />
{props.onboardingStep === "bootstrap" ? "Preparing your workspace" : "Waiting for your host"}
</div>
<div class="mt-2 text-xs leading-6 text-dls-secondary">
{props.onboardingStep === "bootstrap"
? "Starting local services, setting up your starter folder, and opening your first session."
: "Checking your local OpenWork services and reconnecting to the selected workspace."}
</div>
</div>
<Show when={props.error}>
<div class="mt-4 rounded-2xl bg-red-1/40 px-5 py-4 text-sm text-red-12 border border-red-7/20 text-left">
<div class="w-full rounded-2xl bg-red-1/40 px-5 py-4 text-sm text-red-12 border border-red-7/20 text-left">
{props.error}
</div>
</Show>
<Show when={connectingFallbackVisible()}>
<div class="mt-5 flex items-center justify-center gap-2">
<Button variant="secondary" onClick={props.onOpenSettings} disabled={props.busy}>
{translate("onboarding.open_settings")}
</Button>
<Button variant="ghost" onClick={props.onBackToWelcome} disabled={props.busy}>
{translate("onboarding.back")}
<div class="flex flex-col items-center gap-3">
<Button variant="secondary" onClick={props.onOpenAdvancedSettings} disabled={props.busy}>
Having trouble?
</Button>
<div class="text-xs text-gray-10">Open Advanced settings to check local host configuration.</div>
</div>
<div class="mt-3 text-xs text-gray-10">{translate("onboarding.open_settings_hint")}</div>
</Show>
</div>
</div>
</div>

View File

@@ -47,6 +47,7 @@ import {
AlertTriangle,
Check,
Circle,
FolderOpen,
HardDrive,
ListTodo,
Loader2,
@@ -132,6 +133,8 @@ export type SessionViewProps = {
editWorkspaceConnection: (workspaceId: string) => void;
forgetWorkspace: (workspaceId: string) => void;
openCreateWorkspace: () => void;
getStartedWorkspace: () => Promise<boolean>;
pickFolderWorkspace: () => Promise<boolean>;
openCreateRemoteWorkspace: () => void;
importWorkspaceConfig: () => void;
importingWorkspaceConfig: boolean;
@@ -4209,32 +4212,62 @@ export default function SessionView(props: SessionViewProps) {
>
<div class="mx-auto w-full max-w-[800px]">
<Show when={showWorkspaceSetupEmptyState()}>
<div class="mx-auto max-w-xl rounded-[24px] border border-dls-border bg-dls-sidebar p-8 text-center shadow-[var(--dls-card-shadow)]">
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-dls-border bg-dls-surface text-gray-11">
<HardDrive size={24} />
</div>
<h3 class="text-2xl font-semibold text-gray-12">
Set up your first workspace
</h3>
<p class="mt-2 text-sm text-gray-10">
OpenWork needs a local or remote workspace before you can
start a session.
</p>
<div class="mt-6 grid gap-3 sm:grid-cols-2">
<button
type="button"
class="rounded-full border border-gray-7 bg-gray-12 px-5 py-3 text-sm font-semibold text-gray-1 transition-colors hover:bg-gray-11"
onClick={props.openCreateWorkspace}
>
Create local workspace
</button>
<button
type="button"
class="rounded-full border border-dls-border bg-dls-surface px-5 py-3 text-sm font-semibold text-gray-12 transition-colors hover:bg-gray-2"
onClick={props.openCreateRemoteWorkspace}
>
Connect remote workspace
</button>
<div class="mx-auto max-w-2xl rounded-[32px] border border-dls-border bg-dls-sidebar/95 p-5 shadow-[var(--dls-shell-shadow)] sm:p-8">
<div class="rounded-[28px] border border-dls-border bg-dls-surface p-6 sm:p-8">
<div class="flex flex-col gap-6">
<div class="flex flex-col items-center text-center">
<div class="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-dls-border bg-dls-sidebar text-gray-11 shadow-[var(--dls-card-shadow)]">
<HardDrive size={24} />
</div>
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-dls-secondary">
Workspace setup
</div>
<h3 class="mt-3 text-3xl font-semibold tracking-tight text-gray-12">
Set up your first workspace
</h3>
<p class="mt-3 max-w-xl text-sm leading-6 text-dls-secondary sm:text-[15px]">
Start with a guided OpenWork workspace, or choose an existing folder you want to work in.
</p>
</div>
<div class="grid gap-3 sm:grid-cols-[1.2fr_1fr]">
<button
type="button"
class="group rounded-[24px] border border-transparent bg-dls-accent px-5 py-5 text-left text-white shadow-[var(--dls-card-shadow)] transition-all hover:-translate-y-0.5 hover:bg-[var(--dls-accent-hover)]"
onClick={() => void props.getStartedWorkspace()}
>
<div class="flex items-start gap-4">
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-white/20 bg-white/10">
<HardDrive size={18} />
</div>
<div class="min-w-0">
<div class="text-base font-semibold">Get Started</div>
<div class="mt-1 text-sm leading-6 text-white/80">
Start working right away in a new OpenWork folder.
</div>
</div>
</div>
</button>
<button
type="button"
class="group rounded-[24px] border border-dls-border bg-dls-sidebar px-5 py-5 text-left text-gray-12 transition-all hover:-translate-y-0.5 hover:border-gray-7 hover:bg-gray-2/80"
onClick={() => void props.pickFolderWorkspace()}
>
<div class="flex items-start gap-4">
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-dls-border bg-dls-surface text-gray-11">
<FolderOpen size={18} />
</div>
<div class="min-w-0">
<div class="text-base font-semibold">Pick a folder you want to work in</div>
<div class="mt-1 text-sm leading-6 text-dls-secondary">
Choose an existing project or notes folder and OpenWork will use it as your workspace.
</div>
</div>
</div>
</button>
</div>
</div>
</div>
</div>
</Show>
@@ -4581,6 +4614,10 @@ export default function SessionView(props: SessionViewProps) {
props.setTab("mcp");
props.setView("dashboard");
}}
onOpenMessaging={() => {
props.setTab("identities");
props.setView("dashboard");
}}
onOpenAdvanced={openConfig}
onOpenSettings={() => openSettings("general")}
onInboxToast={(message) => setToastMessage(message)}
@@ -4616,6 +4653,10 @@ export default function SessionView(props: SessionViewProps) {
props.setTab("mcp");
props.setView("dashboard");
}}
onOpenMessaging={() => {
props.setTab("identities");
props.setView("dashboard");
}}
onOpenAdvanced={openConfig}
onOpenSettings={() => openSettings("general")}
onInboxToast={(message) => setToastMessage(message)}

View File

@@ -144,7 +144,7 @@ export type StartupPreference = "local" | "server";
export type EngineRuntime = "direct" | "openwork-orchestrator";
export type OnboardingStep = "welcome" | "local" | "server" | "connecting";
export type OnboardingStep = "welcome" | "local" | "server" | "connecting" | "bootstrap";
export type DashboardTab =
| "scheduled"

View File

@@ -86,6 +86,20 @@ export function isWindowsPlatform() {
return /windows/i.test(platform) || /windows/i.test(ua);
}
export function isMacPlatform() {
if (typeof navigator === "undefined") return false;
const ua = typeof navigator.userAgent === "string" ? navigator.userAgent : "";
const platform =
typeof (navigator as any).userAgentData?.platform === "string"
? (navigator as any).userAgentData.platform
: typeof navigator.platform === "string"
? navigator.platform
: "";
return /mac/i.test(platform) || /macintosh|mac os x/i.test(ua);
}
const STARTUP_PREF_KEY = "openwork.startupPref";
const LEGACY_PREF_KEY = "openwork.modePref";
const LEGACY_PREF_KEY_ALT = "openwork_mode_pref";
@@ -191,7 +205,7 @@ export function normalizeDirectoryQueryPath(input?: string | null) {
export function normalizeDirectoryPath(input?: string | null) {
const normalized = normalizeDirectoryQueryPath(input);
if (!normalized) return "";
return isWindowsPlatform() ? normalized.toLowerCase() : normalized;
return isWindowsPlatform() || isMacPlatform() ? normalized.toLowerCase() : normalized;
}
export function normalizeEvent(raw: unknown): OpencodeEvent | null {

View File

@@ -2805,6 +2805,7 @@ dependencies = [
"objc2-foundation",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",

View File

@@ -21,8 +21,9 @@ json5 = "0.4"
notify = "6.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = "2"
tauri = { version = "2", features = [] }
tauri-plugin-deep-link = "2"
sha2 = "0.10"
tauri-plugin-dialog = "2"
tauri-plugin-http = "2.5.6"
tauri-plugin-opener = "2.5.3"

View File

@@ -1,15 +1,15 @@
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use sha2::{Digest, Sha256};
use tauri::Manager;
use crate::types::{WorkspaceState, WORKSPACE_STATE_VERSION};
use crate::types::{WorkspaceState, WorkspaceType, WORKSPACE_STATE_VERSION};
pub fn stable_workspace_id(path: &str) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
path.hash(&mut hasher);
format!("ws-{:x}", hasher.finish())
let digest = Sha256::digest(path.as_bytes());
let hex = format!("{:x}", digest);
format!("ws_{}", &hex[..12])
}
pub fn openwork_state_paths(app: &tauri::AppHandle) -> Result<(PathBuf, PathBuf), String> {
@@ -32,10 +32,47 @@ pub fn load_workspace_state(app: &tauri::AppHandle) -> Result<WorkspaceState, St
let mut state: WorkspaceState = serde_json::from_str(&raw)
.map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
let mut changed_ids = false;
let old_active_id = state.active_id.clone();
for workspace in state.workspaces.iter_mut() {
let next_id = match workspace.workspace_type {
WorkspaceType::Local => stable_workspace_id(&workspace.path),
WorkspaceType::Remote => {
if workspace.remote_type == Some(crate::types::RemoteType::Openwork) {
stable_workspace_id_for_openwork(
workspace.openwork_host_url.as_deref().unwrap_or(""),
workspace.openwork_workspace_id.as_deref(),
)
} else {
stable_workspace_id_for_remote(
workspace.base_url.as_deref().unwrap_or(""),
workspace.directory.as_deref(),
)
}
}
};
if workspace.id != next_id {
if old_active_id == workspace.id {
state.active_id = next_id.clone();
}
workspace.id = next_id;
changed_ids = true;
}
}
if state.version < WORKSPACE_STATE_VERSION {
state.version = WORKSPACE_STATE_VERSION;
}
if changed_ids && state.active_id.is_empty() {
state.active_id = state
.workspaces
.first()
.map(|workspace| workspace.id.clone())
.unwrap_or_default();
}
Ok(state)
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { listCommands, upsertCommand } from "./commands.js";
describe("commands", () => {
test("upsertCommand omits null model from frontmatter", async () => {
const workspace = await mkdtemp(join(tmpdir(), "openwork-commands-"));
const path = await upsertCommand(workspace, {
name: "learn-files",
description: "Learn files",
template: "Show me the files",
model: null,
});
const content = await readFile(path, "utf8");
expect(content).not.toContain("model: null");
expect(content).not.toContain("model:");
});
test("listCommands repairs legacy null model frontmatter", async () => {
const workspace = await mkdtemp(join(tmpdir(), "openwork-commands-"));
const commandsDir = join(workspace, ".opencode", "commands");
const commandPath = join(commandsDir, "learn-files.md");
await mkdir(commandsDir, { recursive: true });
await writeFile(commandPath, "---\nname: learn-files\ndescription: Learn files\nmodel: null\n---\nShow me the files\n", "utf8");
const commands = await listCommands(workspace, "workspace");
expect(commands).toHaveLength(1);
expect(commands[0]?.model).toBeNull();
const repaired = await readFile(commandPath, "utf8");
expect(repaired).not.toContain("model: null");
expect(repaired).not.toContain("model:");
});
});

View File

@@ -8,6 +8,26 @@ import { projectCommandsDir } from "./workspace-files.js";
import { validateCommandName, sanitizeCommandName } from "./validators.js";
import { ApiError } from "./errors.js";
function normalizeCommandFrontmatter(data: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(data).filter(([, value]) => value !== null && value !== undefined),
);
}
async function repairLegacyCommandFile(filePath: string, content: string): Promise<{ data: Record<string, unknown>; body: string }> {
const parsed = parseFrontmatter(content);
if (parsed.data.model !== null) {
return parsed;
}
const nextContent = buildFrontmatter(normalizeCommandFrontmatter(parsed.data)) + parsed.body.replace(/^\n?/, "\n");
await writeFile(filePath, nextContent, "utf8");
return {
data: normalizeCommandFrontmatter(parsed.data),
body: parsed.body,
};
}
async function listCommandsInDir(dir: string, scope: "workspace" | "global"): Promise<CommandItem[]> {
if (!(await exists(dir))) return [];
const entries = await readdir(dir, { withFileTypes: true });
@@ -17,7 +37,7 @@ async function listCommandsInDir(dir: string, scope: "workspace" | "global"): Pr
if (!entry.name.endsWith(".md")) continue;
const filePath = join(dir, entry.name);
const content = await readFile(filePath, "utf8");
const { data, body } = parseFrontmatter(content);
const { data, body } = await repairLegacyCommandFile(filePath, content);
const name = typeof data.name === "string" ? data.name : entry.name.replace(/\.md$/, "");
try {
validateCommandName(name);
@@ -54,13 +74,13 @@ export async function upsertCommand(
}
const sanitized = sanitizeCommandName(payload.name);
validateCommandName(sanitized);
const frontmatter = buildFrontmatter({
const frontmatter = buildFrontmatter(normalizeCommandFrontmatter({
name: sanitized,
description: payload.description,
agent: payload.agent,
model: payload.model ?? null,
model: payload.model,
subtask: payload.subtask ?? false,
});
}));
const content = frontmatter + "\n" + payload.template.trim() + "\n";
const dir = projectCommandsDir(workspaceRoot);
await mkdir(dir, { recursive: true });
@@ -69,6 +89,10 @@ export async function upsertCommand(
return path;
}
export async function repairCommands(workspaceRoot: string): Promise<void> {
await listCommandsInDir(projectCommandsDir(workspaceRoot), "workspace");
}
export async function deleteCommand(workspaceRoot: string, name: string): Promise<void> {
const sanitized = sanitizeCommandName(name);
validateCommandName(sanitized);

View File

@@ -1,3 +1,4 @@
import { existsSync } from "node:fs";
import { readFile, writeFile, rm, readdir, rename, stat } from "node:fs/promises";
import { createHash, randomInt } from "node:crypto";
import { homedir, hostname } from "node:os";
@@ -8,16 +9,18 @@ import { addPlugin, listPlugins, normalizePluginSpec, removePlugin } from "./plu
import { addMcp, listMcp, removeMcp } from "./mcp.js";
import { deleteSkill, listSkills, upsertSkill } from "./skills.js";
import { installHubSkill, listHubSkills } from "./skill-hub.js";
import { deleteCommand, listCommands, upsertCommand } from "./commands.js";
import { deleteCommand, listCommands, repairCommands, upsertCommand } from "./commands.js";
import { deleteScheduledJob, listScheduledJobs, resolveScheduledJob } from "./scheduler.js";
import { ApiError, formatError } from "./errors.js";
import { readJsoncFile, updateJsoncPath, updateJsoncTopLevel, writeJsoncFile } from "./jsonc.js";
import { recordAudit, readAuditEntries, readLastAudit } from "./audit.js";
import { ReloadEventStore } from "./events.js";
import { startReloadWatchers } from "./reload-watcher.js";
import { parseFrontmatter } from "./frontmatter.js";
import { opencodeConfigPath, openworkConfigPath, projectCommandsDir, projectSkillsDir } from "./workspace-files.js";
import { ensureDir, exists, hashToken, shortId } from "./utils.js";
import { workspaceIdForPath } from "./workspaces.js";
import { ensureWorkspaceFiles, readRawOpencodeConfig } from "./workspace-init.js";
import { sanitizeCommandName, validateMcpName } from "./validators.js";
import { TokenService } from "./tokens.js";
import { TOY_UI_CSS, TOY_UI_FAVICON_SVG, TOY_UI_HTML, TOY_UI_JS, cssResponse, htmlResponse, jsResponse, svgResponse } from "./toy-ui.js";
@@ -238,8 +241,13 @@ export function startServer(config: ServerConfig) {
const approvals = new ApprovalService(config.approval);
const reloadEvents = new ReloadEventStore();
const tokens = new TokenService(config);
const routes = createRoutes(config, approvals, tokens);
const logger = createServerLogger(config);
let watcherHandle = startReloadWatchers({ config, reloadEvents, logger });
const restartReloadWatchers = () => {
watcherHandle.close();
watcherHandle = startReloadWatchers({ config, reloadEvents, logger });
};
const routes = createRoutes(config, approvals, tokens, restartReloadWatchers);
const serverOptions: {
hostname: string;
@@ -1184,10 +1192,7 @@ function emitReloadEvent(
reason: ReloadReason,
trigger?: ReloadTrigger,
) {
void reloadEvents;
void workspace;
void reason;
void trigger;
reloadEvents.recordDebounced(workspace.id, reason, trigger);
}
function buildConfigTrigger(path: string): ReloadTrigger {
@@ -1218,7 +1223,12 @@ function serializeWorkspace(workspace: ServerConfig["workspaces"][number]) {
};
}
function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens: TokenService): Route[] {
function createRoutes(
config: ServerConfig,
approvals: ApprovalService,
tokens: TokenService,
onWorkspacesChanged: () => void,
): Route[] {
const routes: Route[] = [];
const fileSessions = new FileSessionStore();
@@ -1394,7 +1404,7 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
addRoute(routes, "GET", "/workspaces", "client", async () => {
const active = config.workspaces[0] ?? null;
const items = config.workspaces.map(serializeWorkspace);
return jsonResponse({ items, activeId: active?.id ?? null });
return jsonResponse({ items, workspaces: items, activeId: active?.id ?? null });
});
addRoute(routes, "GET", "/tokens", "host", async () => {
@@ -1424,6 +1434,91 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
return jsonResponse({ ok: true });
});
addRoute(routes, "POST", "/workspaces/local", "host", async (ctx) => {
ensureWritable(config);
const body = await readJsonBody(ctx.request);
const folderPath = typeof body.folderPath === "string" ? body.folderPath.trim() : "";
const name = typeof body.name === "string" && body.name.trim() ? body.name.trim() : basename(folderPath || "Workspace");
const preset = typeof body.preset === "string" && body.preset.trim() ? body.preset.trim() : "starter";
if (!folderPath) {
throw new ApiError(400, "invalid_payload", "folderPath is required");
}
const workspacePath = resolve(folderPath);
await ensureDir(workspacePath);
await ensureWorkspaceFiles(workspacePath, preset);
const workspace: WorkspaceInfo = {
id: workspaceIdForPath(workspacePath),
name,
path: workspacePath,
preset,
workspaceType: "local",
};
config.workspaces = [workspace, ...config.workspaces.filter((entry) => entry.id !== workspace.id)];
if (!config.authorizedRoots.some((root) => resolve(root) === workspacePath)) {
config.authorizedRoots = [...config.authorizedRoots, workspacePath];
}
const persisted = await persistServerWorkspaceState(config);
onWorkspacesChanged();
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "host" },
action: "workspace.create",
target: workspace.path,
summary: `Created workspace ${name}`,
timestamp: Date.now(),
});
return jsonResponse({
activeId: workspace.id,
workspaces: config.workspaces.map(serializeWorkspace),
persisted,
}, 201);
});
addRoute(routes, "PATCH", "/workspaces/:id/display-name", "host", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const body = await readJsonBody(ctx.request);
const nextDisplayName = typeof body.displayName === "string" && body.displayName.trim()
? body.displayName.trim()
: undefined;
config.workspaces = config.workspaces.map((entry) =>
entry.id === workspace.id
? {
...entry,
displayName: nextDisplayName,
name: nextDisplayName ?? entry.name,
}
: entry,
);
const persisted = await persistServerWorkspaceState(config);
onWorkspacesChanged();
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "host" },
action: "workspace.rename",
target: workspace.path,
summary: `Updated workspace display name${nextDisplayName ? ` to ${nextDisplayName}` : ""}`,
timestamp: Date.now(),
});
return jsonResponse({
activeId: config.workspaces[0]?.id ?? null,
workspaces: config.workspaces.map(serializeWorkspace),
persisted,
});
});
addRoute(routes, "POST", "/workspaces/:id/activate", "host", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
config.workspaces = [
@@ -1439,7 +1534,7 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
summary: "Switched active workspace",
timestamp: Date.now(),
});
return jsonResponse({ activeId: workspace.id, workspace: serializeWorkspace(workspace) });
return jsonResponse({ activeId: workspace.id, workspace: serializeWorkspace(workspace), persisted: false });
});
addRoute(routes, "DELETE", "/workspaces/:id", "host", async (ctx) => {
@@ -1447,12 +1542,6 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
const workspace = await resolveWorkspace(config, ctx.params.id);
// Attempt to persist to server.json (when present) before mutating in-memory state.
const configPath = config.configPath?.trim() ?? "";
const persisted = configPath
? await persistWorkspaceDeletion(configPath, workspace.id, workspace.path)
: false;
const before = config.workspaces.length;
config.workspaces = config.workspaces.filter((entry) => entry.id !== workspace.id);
const deleted = before !== config.workspaces.length;
@@ -1461,6 +1550,8 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
// Only remove exact matches; authorizedRoots can contain broader entries.
config.authorizedRoots = config.authorizedRoots.filter((root) => resolve(root) !== resolve(workspace.path));
}
const persisted = await persistServerWorkspaceState(config);
onWorkspacesChanged();
await recordAudit(workspace.path, {
id: shortId(),
@@ -1479,6 +1570,7 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
persisted,
activeId: active?.id ?? null,
items: config.workspaces.map(serializeWorkspace),
workspaces: config.workspaces.map(serializeWorkspace),
});
});
@@ -1490,6 +1582,58 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
return jsonResponse({ opencode, openwork, updatedAt: lastAudit?.timestamp ?? null });
});
addRoute(routes, "GET", "/workspace/:id/opencode-config", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const scope = normalizeOpencodeScope(ctx.url.searchParams.get("scope"));
const configPath = resolveOpencodeConfigFilePath(scope, workspace.path);
const result = await readRawOpencodeConfig(configPath);
return jsonResponse({ path: configPath, exists: result.exists, content: result.content });
});
addRoute(routes, "POST", "/workspace/:id/opencode-config", "client", async (ctx) => {
ensureWritable(config);
requireClientScope(ctx, "collaborator");
const workspace = await resolveWorkspace(config, ctx.params.id);
const body = await readJsonBody(ctx.request);
const scope = normalizeOpencodeScope(typeof body.scope === "string" ? body.scope : null);
const content = typeof body.content === "string" ? body.content : null;
if (content === null) {
throw new ApiError(400, "invalid_payload", "content must be a string");
}
const configPath = resolveOpencodeConfigFilePath(scope, workspace.path);
await requireApproval(ctx, {
workspaceId: workspace.id,
action: scope === "global" ? "config.global.write" : "config.write",
summary: `Write ${scope} OpenCode config`,
paths: [configPath],
});
await ensureDir(dirname(configPath));
await writeFile(configPath, content.endsWith("\n") ? content : `${content}\n`, "utf8");
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: scope === "global" ? "config.global.write" : "config.write",
target: configPath,
summary: `Updated ${scope} OpenCode config`,
timestamp: Date.now(),
});
if (scope === "project") {
emitReloadEvent(ctx.reloadEvents, workspace, "config", buildConfigTrigger(configPath));
}
return jsonResponse({
ok: true,
status: 0,
stdout: `Wrote ${configPath}`,
stderr: "",
});
});
addRoute(routes, "GET", "/workspace/:id/audit", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const limitParam = ctx.url.searchParams.get("limit");
@@ -2492,8 +2636,10 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
addRoute(routes, "GET", "/workspace/:id/events", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
void ctx;
return jsonResponse({ items: [], cursor: 0, workspaceId: workspace.id, disabled: true });
const sinceRaw = ctx.url.searchParams.get("since");
const since = sinceRaw ? Number(sinceRaw) : undefined;
const items = ctx.reloadEvents.list(workspace.id, since);
return jsonResponse({ items, cursor: ctx.reloadEvents.cursor(), workspaceId: workspace.id, disabled: false });
});
addRoute(routes, "POST", "/workspace/:id/engine/reload", "client", async (ctx) => {
@@ -3803,6 +3949,9 @@ async function resolveWorkspace(config: ServerConfig, id: string): Promise<Works
if (!authorized) {
throw new ApiError(403, "workspace_unauthorized", "Workspace is not authorized");
}
if (!config.readOnly) {
await repairCommands(resolvedWorkspace);
}
return { ...workspace, path: resolvedWorkspace };
}
@@ -3912,62 +4061,55 @@ type OpenworkServerConfigFile = Record<string, unknown> & {
authorizedRoots?: string[];
};
async function persistWorkspaceDeletion(configPath: string, workspaceId: string, workspacePath: string): Promise<boolean> {
if (!configPath.trim()) return false;
async function readServerConfigFile(configPath: string): Promise<OpenworkServerConfigFile> {
if (!(await exists(configPath))) {
// If the server was started from CLI args/env, avoid implicitly creating server.json
// because it can change token behavior on restart.
return false;
return {};
}
let raw = "";
try {
raw = await readFile(configPath, "utf8");
} catch (error) {
throw new ApiError(500, "server_config_read_failed", "Failed to read server config", {
path: configPath,
error: String(error),
});
}
let parsed: OpenworkServerConfigFile;
try {
parsed = ensurePlainObject(JSON.parse(raw)) as OpenworkServerConfigFile;
const raw = await readFile(configPath, "utf8");
return ensurePlainObject(JSON.parse(raw)) as OpenworkServerConfigFile;
} catch (error) {
throw new ApiError(422, "invalid_json", "Failed to parse server config", {
path: configPath,
error: String(error),
});
}
}
const configDir = dirname(configPath);
const workspacesRaw = parsed.workspaces;
const workspaces = Array.isArray(workspacesRaw) ? workspacesRaw : [];
function serializeWorkspaceConfigEntry(workspace: WorkspaceInfo): Record<string, unknown> {
return {
id: workspace.id,
path: workspace.path,
name: workspace.name,
preset: workspace.preset,
workspaceType: workspace.workspaceType,
...(workspace.remoteType ? { remoteType: workspace.remoteType } : {}),
...(workspace.baseUrl ? { baseUrl: workspace.baseUrl } : {}),
...(workspace.directory ? { directory: workspace.directory } : {}),
...(workspace.displayName ? { displayName: workspace.displayName } : {}),
...(workspace.openworkHostUrl ? { openworkHostUrl: workspace.openworkHostUrl } : {}),
...(workspace.openworkToken ? { openworkToken: workspace.openworkToken } : {}),
...(workspace.openworkWorkspaceId ? { openworkWorkspaceId: workspace.openworkWorkspaceId } : {}),
...(workspace.openworkWorkspaceName ? { openworkWorkspaceName: workspace.openworkWorkspaceName } : {}),
...(workspace.sandboxBackend ? { sandboxBackend: workspace.sandboxBackend } : {}),
...(workspace.sandboxRunId ? { sandboxRunId: workspace.sandboxRunId } : {}),
...(workspace.sandboxContainerName ? { sandboxContainerName: workspace.sandboxContainerName } : {}),
...(workspace.opencodeUsername ? { opencodeUsername: workspace.opencodeUsername } : {}),
...(workspace.opencodePassword ? { opencodePassword: workspace.opencodePassword } : {}),
};
}
const nextWorkspaces = workspaces.filter((entry) => {
const obj = ensurePlainObject(entry);
const path = typeof obj.path === "string" ? obj.path.trim() : "";
if (!path) return true;
const id = workspaceIdForPath(resolve(configDir, path));
return id !== workspaceId;
});
const rootsRaw = parsed.authorizedRoots;
const roots = Array.isArray(rootsRaw) ? rootsRaw : [];
const nextRoots = roots.filter((root) => {
const value = typeof root === "string" ? root.trim() : "";
if (!value) return false;
return resolve(configDir, value) !== resolve(workspacePath);
});
const workspacesChanged = nextWorkspaces.length !== workspaces.length;
const rootsChanged = nextRoots.length !== roots.length;
if (!workspacesChanged && !rootsChanged) return false;
async function persistServerWorkspaceState(config: ServerConfig): Promise<boolean> {
const configPath = config.configPath?.trim() ?? "";
if (!configPath) return false;
if (!(await exists(configPath))) return false;
const parsed = await readServerConfigFile(configPath);
const next: OpenworkServerConfigFile = {
...parsed,
...(workspacesChanged ? { workspaces: nextWorkspaces } : {}),
...(rootsChanged ? { authorizedRoots: nextRoots } : {}),
workspaces: config.workspaces.map(serializeWorkspaceConfigEntry),
authorizedRoots: Array.from(new Set(config.authorizedRoots.map((root) => resolve(root)))),
};
await ensureDir(dirname(configPath));
@@ -3985,6 +4127,22 @@ async function persistWorkspaceDeletion(configPath: string, workspaceId: string,
}
}
function normalizeOpencodeScope(value: string | null | undefined): "project" | "global" {
return value?.trim().toLowerCase() === "global" ? "global" : "project";
}
function resolveOpencodeConfigFilePath(scope: "project" | "global", workspaceRoot: string): string {
if (scope === "global") {
const base = join(homedir(), ".config", "opencode");
const jsoncPath = join(base, "opencode.jsonc");
const jsonPath = join(base, "opencode.json");
if (existsSync(jsoncPath)) return jsoncPath;
if (existsSync(jsonPath)) return jsonPath;
return jsoncPath;
}
return opencodeConfigPath(workspaceRoot);
}
function normalizeOpenCodeRouterIdentityId(value: unknown): string {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) return "default";

View File

@@ -1,5 +1,7 @@
export type WorkspaceType = "local" | "remote";
export type RemoteType = "opencode" | "openwork";
export type ApprovalMode = "manual" | "auto";
export type TokenScope = "owner" | "collaborator" | "viewer";
@@ -11,11 +13,22 @@ export type ProviderPlacement = "in-sandbox" | "host-machine" | "client-machine"
export type LogFormat = "pretty" | "json";
export interface WorkspaceConfig {
id?: string;
path: string;
name?: string;
preset?: string;
workspaceType?: WorkspaceType;
remoteType?: RemoteType;
baseUrl?: string;
directory?: string;
displayName?: string;
openworkHostUrl?: string;
openworkToken?: string;
openworkWorkspaceId?: string;
openworkWorkspaceName?: string;
sandboxBackend?: string;
sandboxRunId?: string;
sandboxContainerName?: string;
opencodeUsername?: string;
opencodePassword?: string;
}
@@ -24,9 +37,19 @@ export interface WorkspaceInfo {
id: string;
name: string;
path: string;
preset: string;
workspaceType: WorkspaceType;
remoteType?: RemoteType;
baseUrl?: string;
directory?: string;
displayName?: string;
openworkHostUrl?: string;
openworkToken?: string;
openworkWorkspaceId?: string;
openworkWorkspaceName?: string;
sandboxBackend?: string;
sandboxRunId?: string;
sandboxContainerName?: string;
opencodeUsername?: string;
opencodePassword?: string;
opencode?: {
@@ -37,6 +60,12 @@ export interface WorkspaceInfo {
};
}
export interface OpencodeConfigFile {
path: string;
exists: boolean;
content: string | null;
}
export interface ApprovalConfig {
mode: ApprovalMode;
timeoutMs: number;

View File

@@ -0,0 +1,283 @@
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 <site>" where <site> is the final URL or page title they asked for.
`;
const OPENWORK_AGENT = `---
description: OpenWork default agent (safe, mobile-first, self-referential)
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.
`;
type WorkspaceOpenworkConfig = {
version: number;
workspace?: {
name?: string | null;
createdAt?: number | null;
preset?: string | null;
} | null;
authorizedRoots: string[];
reload?: {
auto?: boolean;
resume?: boolean;
} | null;
};
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<void> {
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<void> {
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<void> {
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<void> {
const path = opencodeConfigPath(workspaceRoot);
const { data } = await readJsoncFile<Record<string, unknown>>(path, {
$schema: "https://opencode.ai/config.json",
});
const next: Record<string, unknown> = 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<string, unknown>) }
: {};
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<void> {
const path = openworkConfigPath(workspaceRoot);
if (await exists(path)) return;
const now = Date.now();
const config: WorkspaceOpenworkConfig = {
version: 1,
workspace: {
name: basename(workspaceRoot) || "Workspace",
createdAt: now,
preset,
},
authorizedRoots: [workspaceRoot],
reload: null,
};
await ensureDir(join(workspaceRoot, ".opencode"));
await writeFile(path, JSON.stringify(config, null, 2) + "\n", "utf8");
}
export async function ensureWorkspaceFiles(workspaceRoot: string, presetInput: string): Promise<void> {
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 ensureOpenworkAgent(workspaceRoot);
await ensureStarterCommands(workspaceRoot, preset);
await ensureOpencodeConfig(workspaceRoot, preset);
await ensureWorkspaceOpenworkConfig(workspaceRoot, preset);
}
export async function readRawOpencodeConfig(path: string): Promise<{ exists: boolean; content: string | null }> {
const hasFile = await exists(path);
if (!hasFile) {
return { exists: false, content: null };
}
const content = await readFile(path, "utf8");
return { exists: true, content };
}

View File

@@ -2,24 +2,69 @@ import { createHash } from "node:crypto";
import { basename, resolve } from "node:path";
import type { WorkspaceConfig, WorkspaceInfo } from "./types.js";
export function workspaceIdForPath(path: string): string {
const hash = createHash("sha256").update(path).digest("hex");
function workspaceIdForKey(key: string): string {
const hash = createHash("sha256").update(key).digest("hex");
return `ws_${hash.slice(0, 12)}`;
}
export function workspaceIdForPath(path: string): string {
return workspaceIdForKey(path);
}
export function workspaceIdForRemote(baseUrl: string, directory?: string | null): string {
const normalizedBaseUrl = baseUrl.trim();
const normalizedDirectory = directory?.trim() ?? "";
const key = normalizedDirectory
? `remote::${normalizedBaseUrl}::${normalizedDirectory}`
: `remote::${normalizedBaseUrl}`;
return workspaceIdForKey(key);
}
export function workspaceIdForOpenwork(hostUrl: string, workspaceId?: string | null): string {
const normalizedHostUrl = hostUrl.trim();
const normalizedWorkspaceId = workspaceId?.trim() ?? "";
const key = normalizedWorkspaceId
? `openwork::${normalizedHostUrl}::${normalizedWorkspaceId}`
: `openwork::${normalizedHostUrl}`;
return workspaceIdForKey(key);
}
export function buildWorkspaceInfos(
workspaces: WorkspaceConfig[],
cwd: string,
): WorkspaceInfo[] {
return workspaces.map((workspace) => {
const resolvedPath = resolve(cwd, workspace.path);
const rawPath = workspace.path?.trim() ?? "";
const workspaceType = workspace.workspaceType ?? "local";
const resolvedPath = rawPath ? resolve(cwd, rawPath) : "";
const remoteType = workspace.remoteType;
const id = workspace.id?.trim()
|| (workspaceType === "remote"
? remoteType === "openwork"
? workspaceIdForOpenwork(workspace.openworkHostUrl ?? workspace.baseUrl ?? "", workspace.openworkWorkspaceId)
: workspaceIdForRemote(workspace.baseUrl ?? "", workspace.directory)
: workspaceIdForPath(resolvedPath));
const name = workspace.name?.trim()
|| workspace.displayName?.trim()
|| workspace.openworkWorkspaceName?.trim()
|| basename(resolvedPath || workspace.directory?.trim() || workspace.baseUrl?.trim() || "Workspace");
return {
id: workspaceIdForPath(resolvedPath),
name: workspace.name ?? basename(resolvedPath),
id,
name,
path: resolvedPath,
workspaceType: workspace.workspaceType ?? "local",
preset: workspace.preset?.trim() || (workspaceType === "remote" ? "remote" : "starter"),
workspaceType,
remoteType,
baseUrl: workspace.baseUrl,
directory: workspace.directory,
displayName: workspace.displayName,
openworkHostUrl: workspace.openworkHostUrl,
openworkToken: workspace.openworkToken,
openworkWorkspaceId: workspace.openworkWorkspaceId,
openworkWorkspaceName: workspace.openworkWorkspaceName,
sandboxBackend: workspace.sandboxBackend,
sandboxRunId: workspace.sandboxRunId,
sandboxContainerName: workspace.sandboxContainerName,
opencodeUsername: workspace.opencodeUsername,
opencodePassword: workspace.opencodePassword,
};

File diff suppressed because one or more lines are too long