diff --git a/AGENTS.md b/AGENTS.md
index 493e2605..ccfff084 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,26 +2,50 @@
Guide for agentic coding assistants working in the Officer monorepo.
+## What Is Officer
+
+Officer is an **AI-powered intranet server** for small and medium businesses. It's a self-hosted platform that gives each team member a personal AI assistant, file storage, terminal, code editor, workspaces, and project management — all under centralized admin control.
+
+**Think of it as**: a self-hosted, AI-native company intranet where every employee gets their own workspace with shared organizational resources and automation.
+
+### Multi-User Architecture
+
+- **Role hierarchy**: Member → Admin → Owner → Super Admin
+- **Bootstrap flow**: First user registers as Super Admin, then invites the team
+- **Per-user isolation**: Files, sessions, settings, workspaces, and tasks are scoped per user under `$DATA_PATH/{email}/`
+- **Shared org resources**: Global tasks/skills/processes, server-level settings (SMTP, AI providers, TTS/STT/OCR), pluggable applications and resources
+- **Multi-scope resolution**: Tasks, skills, and processes resolve user → global → native (built-in), enabling org-wide shared automation
+
+### Core Capabilities
+
+1. **AI Chat** — Multi-provider (Claude, OpenCode, Pi-Mono) with sessions, attachments, speech-to-text, slash commands
+2. **File Browser** — Full filesystem access per user (upload, mkdir, copy, move, delete)
+3. **File Viewer** — Preview video, images, code, text, markdown
+4. **Terminal** — WebSocket-based PTY terminal with Docker sandboxing
+5. **Code Editor** — Monaco-based IDE with file tabs
+6. **Projects** — Project management with per-project workspace layouts, git init
+7. **Workspaces** — Customizable panel-based layouts (split, resize, swap, drag)
+8. **Automation/Skills/Tasks/Processes** — Markdown-based capability definitions with YAML frontmatter
+9. **Dev Server** — Start/stop project dev servers with auto-port discovery and live proxy
+10. **Dashboard Widgets** — Clock, weather, pomodoro, daily goals, quick notes
+11. **Settings** — User preferences, server config, resource management
+
## Quick Start Commands
### Development
```bash
bun dev # Dashboard + API server (port 5000)
-bun dev:tracking # Tracking server (port 5001)
-bun dev:experiments # Experiments server
-bun dev:emailer # Emailer workspace
+bun dev:emailer # Emailer workspace
```
-### Building & Database
+### Building
```bash
bun run prebuild # Run prebuild tasks
+bun run build:web # Build web app
bun run build:dashboard # Build dashboard
bun run build:editor # Build editor (app + extension + runtime)
-bun run build:runtime # Build all runtime scripts
-bun run db:gen && db:push # Generate & push officer_db migrations
-bun run db:gen:stats && db:push:stats # Generate & push statistics_db
```
### Code Quality
@@ -29,11 +53,82 @@ bun run db:gen:stats && db:push:stats # Generate & push statistics_db
```bash
bun format # Format all files (Prettier, required before commit)
bun format:check # Check formatting without writing
-bunx tsgo # TypeScript type checking (comprehensive, slow)
+bunx tsgo # TypeScript type checking
```
**Note:** No automated tests configured yet. Always run `bun format` before committing.
+## Project Structure
+
+```
+src/
+├── apps/
+│ └── officer-web/ # Main web UI (React 19)
+│ ├── Screens/
+│ │ ├── Authentication/ # Login, verify, reset password
+│ │ └── Dashboard/ # All main screens (Home, Files, Chat, Terminal, Projects, etc.)
+│ ├── state/ # App-specific state hooks
+│ ├── lib/ # Utilities
+│ └── locales/ # i18n translations
+│
+├── servers/
+│ ├── api/ # REST API (Hono, port 5000)
+│ │ ├── auth/ # Authentication (JWT + WebAuthn passkeys)
+│ │ ├── users/ # User management (invite, CRUD)
+│ │ ├── sessions/ # Multi-provider chat sessions
+│ │ ├── workspaces/ # Workspace & project state
+│ │ ├── tasks/ # Task definitions (CRUD + chat)
+│ │ ├── skills/ # Skill definitions (CRUD + chat)
+│ │ ├── processes/ # Process definitions (CRUD + chat)
+│ │ ├── file-browser/ # Filesystem access (multi-root)
+│ │ ├── terminal/ # WebSocket PTY terminal
+│ │ ├── dev-server/ # Project dev server management
+│ │ ├── pi/ # AI agent integration
+│ │ ├── scrape/ # Web scraping (Playwright)
+│ │ ├── upload/ # File uploads
+│ │ ├── settings/ # User settings & state
+│ │ ├── server-settings/ # Server-wide config (SMTP, AI, TTS, etc.)
+│ │ ├── dock/ # Dock configuration
+│ │ ├── plans/ # Markdown plans
+│ │ ├── task-logs/ # Task execution logs
+│ │ └── landing-page-data/# Registration status
+│ └── _middlewares/ # Auth, rate limiting, CORS, body parsing
+│
+├── databases/
+│ └── officer_db/ # JSON file-based auth store (users, passkeys, tokens)
+│
+└── workspaces/ # 13 shared packages
+ ├── types/ # Central type re-exports
+ ├── definitions/ # Constants, enums (roles, statuses, devices)
+ ├── config/ # URL configs, env vars
+ ├── helpers/ # cn(), formatters, slug, debounce, queue
+ ├── hooks/ # 90+ hooks (useClient, useForm, useAuth, etc.)
+ ├── state/ # React Query state hooks (useSettings, useChatSessions, etc.)
+ ├── components/ # 89 components (shadcn/ui base + custom)
+ ├── officerdev/ # Core workspace/panel framework + 11 built-in apps
+ ├── i18n/ # Internationalization
+ ├── injector/ # DOM manipulation for visual editing
+ ├── widgets/ # Dashboard widgets (clock, weather, pomodoro, etc.)
+ ├── emailer/ # React-email templates + SMTP
+ └── sounds/ # Audio feedback library
+```
+
+## Path Aliases
+
+- `@/` → `src/apps/officer-web/`
+- `@@/` → `src/servers/`
+- `@/components/*` → `src/workspaces/components/*`
+
+## Tech Stack
+
+- **Runtime**: Bun
+- **Language**: TypeScript 5.9 (strict mode, verbatimModuleSyntax)
+- **Frontend**: React 19, React Router, React Query, Tailwind CSS, shadcn/ui
+- **Backend**: Hono framework, JWT auth, WebAuthn passkeys
+- **Storage**: JSON file-based (auth store + user data), no traditional DB for most data
+- **Build**: Vite
+- **AI**: Claude Agent SDK, multi-provider support
+
## Code Style Guide
### Imports
@@ -48,9 +143,15 @@ import { useExperiment } from 'hooks/use-experiment';
import { formatDate } from '../helpers';
```
+Type-only imports required (verbatimModuleSyntax):
+```ts
+import { ActionModals, type ActionModalsTypes } from './ActionModals';
+import type { FormEvent } from 'react';
+```
+
### TypeScript
-- Strict mode always - no `any`
+- Strict mode always — no `any`
- Prefer `type` over `interface`
- Colocate prop types with components as named exports
- Early returns for null/undefined guards
@@ -61,12 +162,11 @@ import { formatDate } from '../helpers';
- Arrow functions for simple/one-liners
- Regular functions for complex multi-line logic
- Named exports only (never default exports)
+- Extract params type when signature gets long (no multiline params)
```ts
export const formatDate = (ts: number) => new Date(ts).toLocaleDateString();
-export function calculateStats(data: DataPoint[]) {
- /* ... */
-}
+export function calculateStats(data: DataPoint[]) { /* ... */ }
```
### React Components
@@ -82,23 +182,13 @@ export const Card = ({ title, onClick }: CardProps) =>
{t
### State Management
-- **Manager pattern** for complex hooks - return object with state + methods
-- **Colocation** - all feature state in one hook
-- **Derived state** - compute in hook, not in components
-
-```ts
-export const useExperimentManager = (id: number) => {
- const [exp, setExp] = useState
(null);
- const isActive = exp?.status === 'running';
- return {
- exp,
- isActive,
- update: (data) => {
- /* ... */
- },
- };
-};
-```
+- **React Query** for server state
+- **useGlobal()** for UI state (backed by query cache, no Context needed)
+- **useWorkspacesState()** for persistent workspace layouts (server-synced)
+- **useQueryState()** for URL-synced state
+- **usePanelChannel()** for inter-panel pub/sub communication
+- **Manager pattern** for complex hooks — return object with state + methods
+- **Derived state** — compute in hook, not in components
### Naming Conventions
@@ -118,40 +208,13 @@ export const useExperimentManager = (id: number) => {
- Use try/catch for async operations
- Include context in error messages (IDs, resource names)
-- Log with `console.error` and re-throw appropriately
-- Handle database connection errors explicitly
-
-### Async/Await
-
-- Always use async/await (never .then() chains)
-- Minimize nesting
-
-## Project Structure
-
-- `src/apps/dashboard/` - Admin UI (React 19)
-- `src/apps/editor/` - Visual editor
-- `src/servers/api/` - REST API (Hono, port 5000)
-- `src/servers/tracking/` - Event collection (port 5001)
-- `src/databases/` - PostgreSQL schemas (3 DBs: officer_db, statistics_db, ephemeral_db)
-- `src/workspaces/` - Shared: components, hooks, helpers, types
-
-## Path Aliases
-
-- `@/` → `src/apps/dashboard/`
-- `@@/` → `src/servers/`
-
-## Tech Stack
-
-- **Runtime**: Bun
-- **Language**: TypeScript 5.9 (strict mode)
-- **Frontend**: React 19, React Router, React Query, Tailwind CSS, shadcn/ui
-- **Backend**: Hono, PostgreSQL, Drizzle ORM
-- **Build**: Vite
+- Always async/await (never .then() chains)
## General Guidelines
-- **Database-first** approach - schema → API → UI
-- **Self-documenting code** - clear naming, minimal comments
-- **Explicit over implicit** - no magic
-- **Workspace dependencies** - use `workspace:*`
-- **Environment variables** - use `.env`, access directly in code
+- **Database-first** approach — schema → API → UI
+- **Self-documenting code** — clear naming, minimal comments
+- **Explicit over implicit** — no magic
+- **Workspace dependencies** — use `workspace:*`
+- **Multi-user aware** — always consider user isolation and role-based access when adding features
+- **File-based storage** — user data lives under `$DATA_PATH/{email}/`, respect the per-user boundary
diff --git a/seed/skills/mlxaudio/SKILL.md b/seed/skills/mlxaudio/SKILL.md
index 5f10cecb..b07c34c9 100644
--- a/seed/skills/mlxaudio/SKILL.md
+++ b/seed/skills/mlxaudio/SKILL.md
@@ -1,5 +1,5 @@
---
-name: mlx.audio
+name: mlxaudio
description: Generate speech from text and transcribe audio using mlx-audio. Use when the user wants text-to-speech synthesis, speech-to-text transcription, voice cloning, audio separation, or speech-to-speech processing on Apple Silicon.
---
diff --git a/seed/skills/whisper.cpp/SKILL.md b/seed/skills/whisper-cpp/SKILL.md
similarity index 99%
rename from seed/skills/whisper.cpp/SKILL.md
rename to seed/skills/whisper-cpp/SKILL.md
index eb9f7aac..bbb83dfe 100644
--- a/seed/skills/whisper.cpp/SKILL.md
+++ b/seed/skills/whisper-cpp/SKILL.md
@@ -1,5 +1,5 @@
---
-name: whisper.cpp
+name: whisper-cpp
description: Transcribe audio files to text using whisper.cpp. Use when the user wants to transcribe audio, convert speech to text, or extract text from an audio/video file.
---
diff --git a/seed/skills/whisper.cpp/chat/messages.json b/seed/skills/whisper-cpp/chat/messages.json
similarity index 100%
rename from seed/skills/whisper.cpp/chat/messages.json
rename to seed/skills/whisper-cpp/chat/messages.json
diff --git a/seed/skills/whisper.cpp/chat/meta.json b/seed/skills/whisper-cpp/chat/meta.json
similarity index 100%
rename from seed/skills/whisper.cpp/chat/meta.json
rename to seed/skills/whisper-cpp/chat/meta.json
diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx
index 4364702c..9eeebbf6 100644
--- a/src/apps/officer-web/App.tsx
+++ b/src/apps/officer-web/App.tsx
@@ -40,6 +40,7 @@ export function App() {
: } />
: } />
: } />
+ } />
} />
} />
} />
diff --git a/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx b/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx
index 81cd9405..0982e907 100644
--- a/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx
+++ b/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx
@@ -11,7 +11,7 @@ import { useGlobal } from 'hooks/useGlobal';
const initialState: LoginFormState = {
// email: 'pastilhas@pastilhas.dev',
- // password: '1234567890',
+ // password: '',
};
export function Login() {
const [isSubmitting, setIsSubmitting] = useState(false);
diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx
index 1b8d322b..875ed902 100644
--- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx
@@ -70,6 +70,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
{
if (!id) navigate('/chat', { replace: true });
diff --git a/src/apps/officer-web/Screens/Dashboard/Files/FilesScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Files/FilesScreen.tsx
index e72ea7c6..28dfaf85 100644
--- a/src/apps/officer-web/Screens/Dashboard/Files/FilesScreen.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Files/FilesScreen.tsx
@@ -9,7 +9,7 @@ export const FilesScreen = () => {
return (
-
+
);
};
diff --git a/src/apps/officer-web/Screens/Dashboard/Home/HomeScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Home/HomeScreen.tsx
index 3e771782..1ce94d18 100644
--- a/src/apps/officer-web/Screens/Dashboard/Home/HomeScreen.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Home/HomeScreen.tsx
@@ -1,9 +1,17 @@
import type { LayoutNode, PanelComponents, DefaultFileSort } from 'officerdev';
import { WorkspaceView, useFileViewerPanels } from 'officerdev';
import { useWorkspacesState } from 'state/useWorkspacesState';
+import { useSettings } from 'state/useSettings';
+import { Button } from '@/components/ui/button';
import { defaultLayout } from './defaultLayout';
const HomeHeader = () => {
+ const { settings, saveSettings } = useSettings();
+
+ const completeOnboarding = () => {
+ saveSettings({ ...settings, onboarding: { complete: true } });
+ };
+
return (
@@ -11,6 +19,9 @@ const HomeHeader = () => {
Please follow the video instructions below in order to get familiar with all that is possible.
+
+ I'm ready
+
);
@@ -25,10 +36,26 @@ const defaultSort: DefaultFileSort = { field: 'type', direction: 'desc' };
export const HomeScreen = () => {
const workspace = useWorkspacesState('screens/home', defaultLayout);
const ephemeral = useFileViewerPanels();
+ const { settings } = useSettings();
+
+ if (settings.onboarding.complete) {
+ return (
+
+
+
+ );
+ }
return (
-
+
);
};
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx
index 9588006a..dba42ca4 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx
@@ -1,7 +1,7 @@
import { Link } from 'react-router';
import * as Dropdown from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
-import { User, Users, LogOut, Settings, Package, Sun, Moon } from 'lucide-react';
+import { User, Users, LogOut, Settings, Package, Puzzle, Sun, Moon } from 'lucide-react';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
import { useColorMode } from '@/components/ui/ThemeProvider';
@@ -59,6 +59,12 @@ export function UserMenu() {
>
)}
+
+
+
+ Integrations
+
+
{user?.role === 'Super Admin' && (
diff --git a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx
index eb771730..13b986ff 100644
--- a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx
@@ -30,6 +30,7 @@ export const ProjectListScreen = () => {
{
if (!id) setSelected(null);
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleAccount.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleAccount.tsx
new file mode 100644
index 00000000..d7e2f49d
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleAccount.tsx
@@ -0,0 +1,109 @@
+import { useState, useEffect } from 'react';
+import { toast } from 'sonner';
+import { Button } from '@/components/ui/button';
+import { useClient } from 'hooks/useClient';
+
+type GoogleStatus = {
+ connected: boolean;
+ email: string | null;
+ configured: boolean;
+};
+
+export const GoogleAccount = () => {
+ const client = useClient();
+ const [isLoading, setIsLoading] = useState(true);
+ const [status, setStatus] = useState({ connected: false, email: null, configured: false });
+
+ const fetchStatus = () => {
+ client
+ .get('/integrations/google/status')
+ .then(setStatus)
+ .catch(() => {})
+ .finally(() => setIsLoading(false));
+ };
+
+ useEffect(() => {
+ fetchStatus();
+ const params = new URLSearchParams(window.location.search);
+ const result = params.get('google');
+ if (result === 'success') {
+ toast.success('Google account connected');
+ } else if (result === 'error') {
+ toast.error('Failed to connect Google account');
+ }
+ if (result) {
+ window.history.replaceState({}, '', window.location.pathname);
+ }
+ }, []);
+
+ const handleConnect = () => {
+ const params = new URLSearchParams({
+ token: client.token ?? '',
+ origin: window.location.origin,
+ });
+ window.location.href = `/api/integrations/google/authorize?${params.toString()}`;
+ };
+
+ const handleDisconnect = async () => {
+ try {
+ await client.delete('/integrations/google/connection');
+ setStatus({ ...status, connected: false, email: null });
+ toast.success('Google account disconnected');
+ } catch {
+ toast.error('Failed to disconnect Google account');
+ }
+ };
+
+ if (isLoading) return null;
+
+ if (!status.configured) {
+ return (
+
+
+ Google integration has not been configured yet. Ask your administrator to set up Google OAuth credentials in
+ the Enterprise settings.
+
+
+ );
+ }
+
+ if (status.connected) {
+ return (
+
+
+
+
+
Connected
+
{status.email}
+
+
+
+ Officer has access to your Google Calendar, Gmail, and other enabled services.
+
+
+ Disconnect
+
+
+ );
+ }
+
+ return (
+
+
+ Connect your Google account to give Officer access to your Calendar, Gmail, and other Google services.
+
+
+ Connect Google Account
+
+
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleOAuthConfig.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleOAuthConfig.tsx
new file mode 100644
index 00000000..cf590a7b
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleOAuthConfig.tsx
@@ -0,0 +1,288 @@
+import { useState, useEffect } from 'react';
+import { toast } from 'sonner';
+import { ChevronDown } from 'lucide-react';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Button } from '@/components/ui/button';
+import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
+import { useClient } from 'hooks/useClient';
+
+type GoogleOAuthSettings = {
+ clientId: string;
+ clientSecret: string;
+};
+
+const SCOPES = [
+ { scope: 'gmail.readonly', description: 'Read emails' },
+ { scope: 'calendar.readonly', description: 'Read calendar events' },
+];
+
+const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+ Step-by-step setup guide
+
+
+
+
+ Create a Google Cloud project
+
+ Go to the{' '}
+
+ New Project
+ {' '}
+ page. Give it a name (e.g. "Officer") and click Create .
+
+
+
+
+ Enable the APIs
+
+ Go to{' '}
+
+ API Library
+
+ . Search for and enable each of these:
+
+
+ Gmail API
+ Google Calendar API
+
+ Click each one, then click Enable .
+
+
+
+ Configure the OAuth consent screen
+
+ Go to{' '}
+
+ OAuth Branding
+
+ .
+
+
+ Set App name to your organization name or "Officer"
+ Set User support email to your admin email
+ Add your admin email under Developer contact information
+ Click Save
+
+
+
+
+ Set the audience
+
+ Go to{' '}
+
+ OAuth Audience
+
+ .
+
+
+
+ If your team uses Google Workspace, select Internal — no verification needed
+
+
+ Otherwise, select External and add your team's emails under Test users (required while the app is unverified; limit of 100 test users)
+
+
+
+
+
+ Add scopes
+
+ In the left sidebar, click{' '}
+
+ Data Access
+
+ , then click Add or remove scopes . Search for and add:
+
+
+ {SCOPES.map((s) => (
+
+
+ {s.scope}
+ {' '}
+ — {s.description}
+
+ ))}
+
+ Click Update , then Save .
+
+ Note: calendar.readonly is classified as sensitive and{' '}
+ gmail.readonly as restricted by Google.
+ This is fine for Internal apps (Google Workspace) and External apps in testing mode. Publishing to production with restricted scopes requires Google verification.
+
+
+
+
+ Create OAuth credentials
+
+ In the left sidebar, click{' '}
+
+ Clients
+
+ , then click Create OAuth client .
+
+
+ Application type: Web application
+ Name: anything (e.g. "Officer")
+
+ Authorized redirect URIs: add{' '}
+
+ {redirectUri}
+
+
+ Click Create
+
+
+
+
+ Copy the credentials
+
+ A dialog will show your Client ID and Client Secret . Copy both and paste them into the fields below.
+
+
+
+
+
+ );
+};
+
+type VerifyStatus = { valid: boolean; error: string | null } | null;
+
+const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVerifying: boolean }) => {
+ if (isVerifying) {
+ return (
+
+
+
Verifying credentials...
+
+ );
+ }
+
+ if (!status) return null;
+
+ return (
+
+
+
+ {status.valid ? 'Credentials valid' : status.error ?? 'Invalid credentials'}
+
+
+ );
+};
+
+export const GoogleOAuthConfig = () => {
+ const client = useClient();
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+ const [clientId, setClientId] = useState('');
+ const [clientSecret, setClientSecret] = useState('');
+ const [verifyStatus, setVerifyStatus] = useState(null);
+ const [isVerifying, setIsVerifying] = useState(false);
+
+ const verify = () => {
+ setIsVerifying(true);
+ client
+ .get<{ valid: boolean; error: string | null }>('/integrations/google/verify')
+ .then(setVerifyStatus)
+ .catch(() => setVerifyStatus({ valid: false, error: 'Verification request failed' }))
+ .finally(() => setIsVerifying(false));
+ };
+
+ useEffect(() => {
+ client
+ .get('/integrations/google/config')
+ .then((data) => {
+ if (data) {
+ setClientId(data.clientId);
+ setClientSecret(data.clientSecret);
+ }
+ })
+ .catch(() => {})
+ .finally(() => {
+ setIsLoading(false);
+ });
+ }, []);
+
+ // Verify on load if credentials exist
+ useEffect(() => {
+ if (!isLoading && clientId && clientSecret) verify();
+ }, [isLoading]);
+
+ const handleSave = async () => {
+ if (isSaving) return;
+ setIsSaving(true);
+ try {
+ await client.put('/integrations/google/config', { clientId: clientId.trim(), clientSecret: clientSecret.trim() });
+ toast.success('Google OAuth configuration saved');
+ verify();
+ } catch {
+ toast.error('Failed to save Google OAuth configuration');
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ if (isLoading) return null;
+
+ const redirectUri = `${window.location.origin}/api/integrations/google/callback`;
+
+ return (
+
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx
new file mode 100644
index 00000000..53f7e256
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx
@@ -0,0 +1,90 @@
+import { useMemo } from 'react';
+import { Puzzle, KeyRound, UserCircle } from 'lucide-react';
+import type { LayoutNode, PanelComponents } from 'officerdev';
+import { WorkspaceLayout } from 'officerdev';
+import { useAuth } from 'hooks/useAuth';
+import { useGlobal } from 'hooks/useGlobal';
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
+
+import { SettingsSidebar, SettingsContent, type SettingsSection } from '../SettingsPanel';
+import { GoogleOAuthConfig } from './GoogleOAuthConfig';
+import { GoogleAccount } from './GoogleAccount';
+
+const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED';
+const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB';
+
+const enterpriseSections: SettingsSection[] = [
+ { key: 'google-oauth', icon: KeyRound, title: 'Google OAuth', description: 'Client ID and secret for Google APIs', content: },
+];
+
+const personalSections: SettingsSection[] = [
+ { key: 'google-account', icon: UserCircle, title: 'Google Account', description: 'Connect your Google account', content: },
+];
+
+const IntegrationsSidebar = () => {
+ const { user } = useAuth();
+ const isSuperAdmin = user?.role === 'Super Admin';
+ const [tab, setTab] = useGlobal(TAB_KEY, isSuperAdmin ? 'enterprise' : 'personal');
+ const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
+
+ return (
+
+
+ {isSuperAdmin && (
+
+
+
+
+ Enterprise
+
+
+ Personal
+
+
+
+
+ )}
+
+
+ );
+};
+
+const IntegrationsContent = () => {
+ const { user } = useAuth();
+ const isSuperAdmin = user?.role === 'Super Admin';
+ const [tab] = useGlobal(TAB_KEY, isSuperAdmin ? 'enterprise' : 'personal');
+ const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
+
+ return ;
+};
+
+const layout: LayoutNode = {
+ type: 'group',
+ id: 'integrations-root',
+ direction: 'horizontal',
+ children: [
+ { node: { type: 'panel', id: 'integrations-left', appType: null }, size: 20 },
+ { node: { type: 'panel', id: 'integrations-right', appType: null }, size: 80 },
+ ],
+};
+
+export const IntegrationsSettings = () => {
+ const panelComponents: PanelComponents = useMemo(
+ () => ({
+ 'integrations-left': IntegrationsSidebar,
+ 'integrations-right': IntegrationsContent,
+ }),
+ [],
+ );
+
+ return (
+
+ {}} components={panelComponents} />
+
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx
index aa9c9474..681423a1 100644
--- a/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx
@@ -24,6 +24,7 @@ type SettingsSidebarProps = {
label: string;
sections: SettingsSection[];
groups?: SettingsSectionGroup[];
+ hideHeader?: boolean;
};
const SectionButton = ({
@@ -50,7 +51,7 @@ const SectionButton = ({
);
-export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups }: SettingsSidebarProps) => {
+export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups, hideHeader }: SettingsSidebarProps) => {
const allSections = groups ? groups.flatMap((g) => g.sections) : sections;
const [selectedKey, setSelectedKey] = useGlobal(globalKey, allSections[0]?.key ?? null);
const [search, setSearch] = useState('');
@@ -61,12 +62,14 @@ export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups
return (
-
-
-
- {label}
+ {!hideHeader && (
+
-
+ )}
setSearch(ev.target.value)} className="h-8 text-xs" />
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx
index 19193b65..08a0b741 100644
--- a/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx
@@ -2,3 +2,4 @@ export * from './ProfileSettings';
export * from './SystemSettings';
export * from './ResourceSettings';
export * from './UserSettings';
+export * from './IntegrationsSettings';
diff --git a/src/apps/officer-web/Screens/Dashboard/Terminal/TerminalScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Terminal/TerminalScreen.tsx
index dd38b3de..3489b233 100644
--- a/src/apps/officer-web/Screens/Dashboard/Terminal/TerminalScreen.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Terminal/TerminalScreen.tsx
@@ -8,7 +8,7 @@ export const TerminalScreen = () => {
return (
-
+
);
};
diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspacesScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspacesScreen.tsx
index a783c17f..67e2af6d 100644
--- a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspacesScreen.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspacesScreen.tsx
@@ -15,6 +15,7 @@ export const WorkspacesScreen = () => {
{
if (!id) setSelected(null);
diff --git a/src/servers/api/dock/dock.ts b/src/servers/api/dock/dock.ts
index 5250b730..61e72bb0 100644
--- a/src/servers/api/dock/dock.ts
+++ b/src/servers/api/dock/dock.ts
@@ -14,8 +14,11 @@ dockRouter.get('/', async (ctx) => {
const file = Bun.file(filePath);
if (await file.exists()) {
- const data = await file.json();
- return ctx.json(data);
+ try {
+ return ctx.json(await file.json());
+ } catch {
+ // corrupted file — treat as missing
+ }
}
return ctx.json(null);
diff --git a/src/servers/api/integrations/integrations.ts b/src/servers/api/integrations/integrations.ts
new file mode 100644
index 00000000..cb4899e0
--- /dev/null
+++ b/src/servers/api/integrations/integrations.ts
@@ -0,0 +1,218 @@
+import { mkdir } from 'node:fs/promises';
+import { dirname, join } from 'node:path';
+import { homedir } from 'node:os';
+import { createRouter } from '../../create-router';
+import { DATA_PATH } from '@@/data-path';
+import { CustomError } from '../../custom-errors';
+
+const configDir = `${homedir()}/.config/officer.dev`;
+const googleConfigPath = join(configDir, 'google-oauth.json');
+
+const GOOGLE_SCOPES = [
+ 'https://www.googleapis.com/auth/gmail.readonly',
+ 'https://www.googleapis.com/auth/calendar.readonly',
+ 'https://www.googleapis.com/auth/userinfo.email',
+];
+
+const ensureDir = (filePath: string) => mkdir(dirname(filePath), { recursive: true });
+
+export const readGoogleConfig = async () => {
+ try {
+ return await Bun.file(googleConfigPath).json();
+ } catch {
+ return null;
+ }
+};
+
+const getUserGoogleFile = (email: string) => join(DATA_PATH, email, 'integrations', 'google.json');
+
+const readUserGoogle = async (email: string) => {
+ try {
+ return await Bun.file(getUserGoogleFile(email)).json();
+ } catch {
+ return null;
+ }
+};
+
+const writeUserGoogle = async (email: string, data: Record) => {
+ const filePath = getUserGoogleFile(email);
+ await ensureDir(filePath);
+ await Bun.write(filePath, JSON.stringify(data, null, 2));
+};
+
+export const integrationsRouter = createRouter();
+
+integrationsRouter.get('/', async (ctx) => {
+ return ctx.json([]);
+});
+
+// --- Enterprise: Google OAuth config (Super Admin only) ---
+
+integrationsRouter.get('/google/config', async (ctx) => {
+ const user = ctx.get('user');
+ if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403);
+ return ctx.json(await readGoogleConfig());
+});
+
+integrationsRouter.put('/google/config', async (ctx) => {
+ const user = ctx.get('user');
+ if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403);
+
+ const body = ctx.get('body') as { clientId?: string; clientSecret?: string };
+ const config = { clientId: body.clientId ?? '', clientSecret: body.clientSecret ?? '' };
+
+ await ensureDir(googleConfigPath);
+ await Bun.write(googleConfigPath, JSON.stringify(config, null, 2));
+ return ctx.json(config);
+});
+
+integrationsRouter.get('/google/verify', async (ctx) => {
+ const user = ctx.get('user');
+ if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403);
+
+ const config = await readGoogleConfig();
+ if (!config?.clientId || !config?.clientSecret) {
+ return ctx.json({ valid: false, error: 'Missing credentials' });
+ }
+
+ // Send a dummy token exchange — valid credentials return "invalid_grant",
+ // invalid credentials return "invalid_client"
+ const res = await fetch('https://oauth2.googleapis.com/token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ client_id: config.clientId,
+ client_secret: config.clientSecret,
+ code: 'invalid_code',
+ redirect_uri: 'https://localhost',
+ grant_type: 'authorization_code',
+ }),
+ });
+
+ const body = await res.json();
+ const valid = body.error === 'invalid_grant' || body.error === 'redirect_uri_mismatch';
+
+ return ctx.json({ valid, error: valid ? null : body.error_description ?? body.error });
+});
+
+// --- Personal: Google account connection status ---
+
+integrationsRouter.get('/google/status', async (ctx) => {
+ const email = ctx.get('user').email;
+ const config = await readGoogleConfig();
+ const connection = await readUserGoogle(email);
+
+ return ctx.json({
+ configured: !!(config?.clientId && config?.clientSecret),
+ connected: !!connection?.accessToken,
+ email: connection?.email ?? null,
+ });
+});
+
+integrationsRouter.delete('/google/connection', async (ctx) => {
+ const email = ctx.get('user').email;
+ const filePath = getUserGoogleFile(email);
+
+ const file = Bun.file(filePath);
+ if (await file.exists()) {
+ await Bun.write(filePath, '{}');
+ }
+
+ return ctx.json({ ok: true });
+});
+
+// --- OAuth flow: authorize (protected — user must be logged in) ---
+
+integrationsRouter.get('/google/authorize', async (ctx) => {
+ const config = await readGoogleConfig();
+ if (!config?.clientId || !config?.clientSecret) {
+ throw new CustomError('Google OAuth not configured', 400);
+ }
+
+ const email = ctx.get('user').email;
+ const origin = ctx.req.query('origin');
+ if (!origin) throw new CustomError('Missing origin parameter', 400);
+ const redirectUri = `${origin}/api/integrations/google/callback`;
+
+ const state = Buffer.from(JSON.stringify({ email, redirectUri })).toString('base64url');
+
+ const params = new URLSearchParams({
+ client_id: config.clientId,
+ redirect_uri: redirectUri,
+ response_type: 'code',
+ scope: GOOGLE_SCOPES.join(' '),
+ access_type: 'offline',
+ prompt: 'consent',
+ state,
+ });
+
+ return ctx.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`);
+});
+
+// --- OAuth callback (public — called by Google, exported for hono.ts) ---
+
+export const googleCallbackHandler = async (ctx: any) => {
+ const code = ctx.req.query('code');
+ const stateParam = ctx.req.query('state');
+ const error = ctx.req.query('error');
+
+ if (error || !code || !stateParam) {
+ return ctx.redirect('/settings/integrations?google=error');
+ }
+
+ let email: string;
+ let redirectUri: string;
+ try {
+ const parsed = JSON.parse(Buffer.from(stateParam, 'base64url').toString());
+ email = parsed.email;
+ redirectUri = parsed.redirectUri;
+ } catch {
+ return ctx.redirect('/settings/integrations?google=error');
+ }
+
+ const config = await readGoogleConfig();
+ if (!config?.clientId || !config?.clientSecret) {
+ return ctx.redirect('/settings/integrations?google=error');
+ }
+
+ // Exchange code for tokens
+ const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ code,
+ client_id: config.clientId,
+ client_secret: config.clientSecret,
+ redirect_uri: redirectUri,
+ grant_type: 'authorization_code',
+ }),
+ });
+
+ if (!tokenResponse.ok) {
+ console.error('Google token exchange failed:', await tokenResponse.text());
+ return ctx.redirect('/settings/integrations?google=error');
+ }
+
+ const tokens = await tokenResponse.json();
+
+ // Fetch the user's Google email
+ const userinfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
+ headers: { Authorization: `Bearer ${tokens.access_token}` },
+ });
+
+ let googleEmail = email;
+ if (userinfoResponse.ok) {
+ const userinfo = await userinfoResponse.json();
+ googleEmail = userinfo.email ?? email;
+ }
+
+ await writeUserGoogle(email, {
+ accessToken: tokens.access_token,
+ refreshToken: tokens.refresh_token,
+ expiresAt: Date.now() + tokens.expires_in * 1000,
+ email: googleEmail,
+ scope: tokens.scope,
+ });
+
+ return ctx.redirect('/settings/integrations?google=success');
+};
diff --git a/src/servers/api/pi/pi-bridge.ts b/src/servers/api/pi/pi-bridge.ts
index f5498d37..cbb10b17 100644
--- a/src/servers/api/pi/pi-bridge.ts
+++ b/src/servers/api/pi/pi-bridge.ts
@@ -1,13 +1,33 @@
import { join, relative } from "path";
+import { readdirSync, existsSync, mkdirSync } from "node:fs";
import type { Subprocess } from "bun";
import type { PiEvent, MessageCost } from "./types";
import { readApiKeys } from "../server-settings/pi-mono";
-import { PI_CONFIG_DIR } from "../../data-path";
+import { PI_CONFIG_DIR, getGlobalSkillsDir, getUserSkillsDir } from "../../data-path";
import { ensureDockerContainer } from "../terminal/websocket";
import { logger } from "./logger";
export type PiEventHandler = (event: PiEvent) => void;
+function collectSkillFlags(email: string): string[] {
+ const flags: string[] = [];
+ const dirs = [getGlobalSkillsDir(), getUserSkillsDir(email)];
+
+ for (const dir of dirs) {
+ if (!existsSync(dir)) continue;
+ const entries = readdirSync(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ const skillFile = join(dir, entry.name, 'SKILL.md');
+ if (existsSync(skillFile)) {
+ flags.push('--skill', join(dir, entry.name));
+ }
+ }
+ }
+
+ return flags;
+}
+
type SandboxOptions = {
userId: number;
username: string;
@@ -18,6 +38,7 @@ type SandboxOptions = {
export async function spawnPi(
cwd: string,
model: string,
+ email: string,
onEvent: PiEventHandler,
sandbox?: SandboxOptions,
): Promise {
@@ -59,9 +80,14 @@ export async function spawnPi(
logger.info('Spawned Pi in container', { containerId, model });
} else {
const storedKeys = await readApiKeys();
- const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
+ const skillFlags = collectSkillFlags(email);
+ const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags];
if (model) args.push('--model', model);
+ if (!existsSync(cwd)) {
+ mkdirSync(cwd, { recursive: true });
+ }
+
proc = Bun.spawn(args, {
cwd,
stdin: 'pipe',
@@ -69,6 +95,8 @@ export async function spawnPi(
stderr: 'pipe',
env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR },
});
+
+ logger.info('Spawned Pi locally', { model, skills: skillFlags.filter((f) => f !== '--skill').length });
}
// Read stdout JSON event stream (runs in background)
diff --git a/src/servers/api/pi/websocket.ts b/src/servers/api/pi/websocket.ts
index 72653a54..6eb36cf1 100644
--- a/src/servers/api/pi/websocket.ts
+++ b/src/servers/api/pi/websocket.ts
@@ -36,23 +36,25 @@ type WSData = {
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
-const resolveRoot = (email: string, root?: string) => {
- if (!root || root === 'home') return getHomeDir(email);
- if (root === '~') return homedir();
- if (root === 'officer.dev') return resolve(process.cwd(), '..');
- return getHomeDir(email);
+const resolveSandboxedCwd = (email: string, cwdRoot?: string, cwd?: string) => {
+ const root = !cwdRoot || cwdRoot === 'home' ? getHomeDir(email) : getHomeDir(email);
+ if (!cwd || cwd === '~') return root;
+ if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
+ if (cwd.startsWith('/')) return join(root, cwd.slice(1));
+ return root;
};
-const resolveCwd = (home: string, cwd?: string) => {
- if (!cwd || cwd === '~') return home;
- if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
- if (cwd.startsWith('/')) return join(home, cwd.slice(1));
- return home;
+const resolveHostCwd = (cwdRoot?: string, cwd?: string) => {
+ if (cwdRoot === 'officer.dev') return resolve(process.cwd(), '..');
+ const root = homedir();
+ if (!cwd || cwd === '~') return root;
+ if (cwd.startsWith('/')) return cwd;
+ if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
+ return join(root, cwd);
};
export const resolveBaseCwd = (email: string, cwdRoot?: string, cwd?: string) => {
- const root = resolveRoot(email, cwdRoot);
- return resolveCwd(root, cwd);
+ return resolveHostCwd(cwdRoot, cwd);
};
const wsToSessionMap = new WeakMap();
@@ -271,11 +273,11 @@ async function handleChat(
});
const homeDir = getHomeDir(email);
- const rootDir = resolveRoot(email, msg.cwdRoot);
- const cwd = resolveCwd(rootDir, msg.cwd);
- const groupSlug = msg.groupSlug || null;
-
const sandboxed = msg.sandboxed ?? false;
+ const cwd = sandboxed
+ ? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
+ : resolveHostCwd(msg.cwdRoot, msg.cwd);
+ const groupSlug = msg.groupSlug || null;
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug);
session.sandboxed = sandboxed;
session.userId = userId;
@@ -286,7 +288,7 @@ async function handleChat(
if (!session.piProcess) {
try {
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
- session.piProcess = await piBridge.spawnPi(cwd, model, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined);
+ session.piProcess = await piBridge.spawnPi(cwd, model, email, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined);
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed });
} catch (err) {
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
@@ -355,7 +357,7 @@ async function handleResume(
const homeDir = getHomeDir(email);
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
- session.piProcess = await piBridge.spawnPi(session.cwd, session.model, onEvent, sandbox);
+ session.piProcess = await piBridge.spawnPi(session.cwd, session.model, email, onEvent, sandbox);
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed });
} catch (err) {
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
diff --git a/src/servers/api/server-settings/server-settings.ts b/src/servers/api/server-settings/server-settings.ts
index 2e1b0256..6d2be075 100644
--- a/src/servers/api/server-settings/server-settings.ts
+++ b/src/servers/api/server-settings/server-settings.ts
@@ -34,19 +34,22 @@ serverSettingsRouter.route('/tts', ttsRouter);
serverSettingsRouter.route('/stt', sttRouter);
serverSettingsRouter.route('/ocr', ocrRouter);
+const readSettings = async () => {
+ try { return await Bun.file(settingsPath).json(); } catch { return {}; }
+};
+
serverSettingsRouter.get('/settings', async (ctx) => {
- const settings = await Bun.file(settingsPath).json();
- return ctx.json(settings);
+ return ctx.json(await readSettings());
});
serverSettingsRouter.get('/onboarding-complete', async (ctx) => {
- const settings = await Bun.file(settingsPath).json();
+ const settings = await readSettings();
return ctx.json({ onboardingComplete: !!settings.onboardingComplete });
});
serverSettingsRouter.put('/', async (ctx) => {
const body = await ctx.req.json();
- const settings = await Bun.file(settingsPath).json();
+ const settings = await readSettings();
const updated = { ...settings, ...body };
await Bun.write(settingsPath, JSON.stringify(updated, null, 2));
return ctx.json(updated);
diff --git a/src/servers/api/sessions/sessions.ts b/src/servers/api/sessions/sessions.ts
index adc6b783..8a69bacc 100644
--- a/src/servers/api/sessions/sessions.ts
+++ b/src/servers/api/sessions/sessions.ts
@@ -34,7 +34,7 @@ sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => {
if (provider === 'claude') {
const file = Bun.file(join(getSessionDir(email, id), 'messages.json'));
if (!(await file.exists())) return ctx.json([]);
- return ctx.json(await file.json());
+ try { return ctx.json(await file.json()); } catch { return ctx.json([]); }
}
if (provider === 'opencode') {
@@ -44,7 +44,7 @@ sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => {
if (provider === 'pi-mono') {
const file = Bun.file(join(getPiMonoSessionDir(email, id), 'messages.json'));
if (!(await file.exists())) return ctx.json([]);
- return ctx.json(await file.json());
+ try { return ctx.json(await file.json()); } catch { return ctx.json([]); }
}
return ctx.json({ error: 'invalid provider' }, 400);
@@ -78,7 +78,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
const dir = getSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
- const meta = await metaFile.json();
+ let meta: Record;
+ try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
return ctx.json({ ok: true });
@@ -88,7 +89,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
const dir = getOpencodeSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
- const meta = await metaFile.json();
+ let meta: Record;
+ try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
@@ -106,7 +108,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
const dir = getPiMonoSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
- const meta = await metaFile.json();
+ let meta: Record;
+ try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
return ctx.json({ ok: true });
diff --git a/src/servers/api/settings/settings.ts b/src/servers/api/settings/settings.ts
index 45495b43..e65e867c 100644
--- a/src/servers/api/settings/settings.ts
+++ b/src/servers/api/settings/settings.ts
@@ -27,8 +27,11 @@ settingsRouter.get('/settings', async (ctx) => {
const file = Bun.file(filePath);
if (await file.exists()) {
- const data = await file.json();
- return ctx.json(data);
+ try {
+ return ctx.json(await file.json());
+ } catch {
+ // corrupted — fall through to defaults
+ }
}
await ensureDir(filePath);
@@ -54,8 +57,11 @@ settingsRouter.get('/state', async (ctx) => {
const file = Bun.file(filePath);
if (await file.exists()) {
- const data = await file.json();
- return ctx.json(data);
+ try {
+ return ctx.json(await file.json());
+ } catch {
+ // corrupted — fall through to empty
+ }
}
await ensureDir(filePath);
@@ -72,7 +78,7 @@ settingsRouter.patch('/state', async (ctx) => {
let existing: Record = {};
if (await file.exists()) {
- existing = await file.json();
+ try { existing = await file.json(); } catch { /* corrupted — start fresh */ }
}
const merged = { ...existing, ...body };
diff --git a/src/servers/api/workspaces/utils.ts b/src/servers/api/workspaces/utils.ts
index 64d5a7c7..9a8ba01f 100644
--- a/src/servers/api/workspaces/utils.ts
+++ b/src/servers/api/workspaces/utils.ts
@@ -72,9 +72,13 @@ export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null {
}
export async function readJsonFile(path: string): Promise {
- const file = Bun.file(path);
- if (await file.exists()) return file.json();
- return null;
+ try {
+ const file = Bun.file(path);
+ if (!(await file.exists())) return null;
+ return await file.json();
+ } catch {
+ return null;
+ }
}
export async function writeJsonFile(path: string, data: unknown) {
@@ -86,7 +90,8 @@ export async function migrateFromState(email: string, dirs: ResolveDirs) {
const file = Bun.file(stateFile);
if (!(await file.exists())) return;
- const state = (await file.json()) as Record;
+ let state: Record;
+ try { state = (await file.json()) as Record; } catch { return; }
const wsKeys = Object.keys(state).filter(
(k) => k === 'workspaces' || k.startsWith('ws-layout-') || k.startsWith('ws-terminals-') || k.startsWith('ws-host-terminals-'),
);
diff --git a/src/servers/bootstrap.ts b/src/servers/bootstrap.ts
index a31292ba..a550f540 100644
--- a/src/servers/bootstrap.ts
+++ b/src/servers/bootstrap.ts
@@ -5,6 +5,7 @@ import { DATA_PATH, PI_CONFIG_DIR } from './data-path';
import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config';
import { syncAllUserPiConfigs } from './api/server-settings/sync-user-pi-config';
import { initAuthStore } from 'officerdb';
+import { syncSeedSkills } from './sync-skills';
mkdirSync(DATA_PATH, { recursive: true });
mkdirSync(PI_CONFIG_DIR, { recursive: true });
@@ -69,6 +70,7 @@ function seedPiConfig(): void {
}
seedPiConfig();
+ syncSeedSkills();
await syncLocalProvidersToPiConfig().catch(err => {
console.error('[bootstrap] Failed to sync local providers to Pi config:', err);
diff --git a/src/servers/hono.ts b/src/servers/hono.ts
index ea6674c0..516e4e77 100644
--- a/src/servers/hono.ts
+++ b/src/servers/hono.ts
@@ -20,6 +20,7 @@ import { router as fileBrowserRouter } from './api/file-browser/router';
import { piRestRouter } from './api/pi/rest';
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
import { dockRouter } from './api/dock/dock';
+import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
import { CustomError } from './custom-errors';
import { userMiddleware, bodyParser } from './_middlewares';
@@ -42,6 +43,7 @@ honoServer.route('/api/auth', authRouter);
honoServer.route('/api/server-settings', serverSettingsRouter);
honoServer.route('/api/landing-page-data', landingPageDataRouter);
honoServer.route('/api/dev-server-proxy', devServerProxyRouter);
+honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
const protectedRouter = createRouter();
protectedRouter.use(bodyParser());
@@ -61,6 +63,7 @@ protectedRouter.route('/task-logs', taskLogsRouter);
protectedRouter.route('/file-browser', fileBrowserRouter);
protectedRouter.route('/dev-server', devServerRouter);
protectedRouter.route('/dock', dockRouter);
+protectedRouter.route('/integrations', integrationsRouter);
protectedRouter.route('/', piRestRouter);
honoServer.route('/api', protectedRouter);
diff --git a/src/servers/sync-skills.ts b/src/servers/sync-skills.ts
new file mode 100644
index 00000000..273bcbd6
--- /dev/null
+++ b/src/servers/sync-skills.ts
@@ -0,0 +1,32 @@
+import { readdirSync, existsSync, mkdirSync, cpSync } from 'node:fs';
+import { join } from 'node:path';
+import { SEED_PATH, DATA_PATH } from './data-path';
+
+const SEED_SKILLS_DIR = join(SEED_PATH, 'skills');
+const GLOBAL_SKILLS_DIR = join(DATA_PATH, 'skills');
+
+export function syncSeedSkills(): void {
+ if (!existsSync(SEED_SKILLS_DIR)) return;
+
+ mkdirSync(GLOBAL_SKILLS_DIR, { recursive: true });
+
+ const seedEntries = readdirSync(SEED_SKILLS_DIR, { withFileTypes: true });
+
+ for (const entry of seedEntries) {
+ if (!entry.isDirectory()) continue;
+
+ const seedSkillDir = join(SEED_SKILLS_DIR, entry.name);
+ const skillFile = join(seedSkillDir, 'SKILL.md');
+ if (!existsSync(skillFile)) continue;
+
+ const targetDir = join(GLOBAL_SKILLS_DIR, entry.name);
+
+ if (existsSync(targetDir)) {
+ // Skill already exists in DATA_PATH — skip to preserve user edits
+ continue;
+ }
+
+ cpSync(seedSkillDir, targetDir, { recursive: true });
+ console.log(`[skills] Synced seed skill: ${entry.name}`);
+ }
+}
diff --git a/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx b/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx
index be7613a1..dffd4a29 100644
--- a/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx
+++ b/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx
@@ -27,6 +27,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
useEffect(() => {
const v = videoRef.current;
if (!v) return;
+ let blobUrl: string | null = null;
const onLoaded = () => {
setDuration(v.duration);
setLoaded(true);
@@ -35,13 +36,33 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
const onEnded = () => setPlaying(false);
+ let fetching = false;
+ let fetchDone = false;
const onError = () => {
- if (fallbackSrc && v.src !== fallbackSrc) {
- v.src = fallbackSrc;
- v.load();
- } else {
+ if (fetching) return;
+ if (fetchDone) {
setError(true);
+ return;
}
+ fetching = true;
+ const fetchUrl = fallbackSrc || src;
+ fetch(fetchUrl)
+ .then((res) => {
+ if (!res.ok) throw new Error();
+ return res.blob();
+ })
+ .then((blob) => {
+ fetching = false;
+ fetchDone = true;
+ blobUrl = URL.createObjectURL(blob);
+ v.src = blobUrl;
+ v.load();
+ })
+ .catch(() => {
+ fetching = false;
+ fetchDone = true;
+ setError(true);
+ });
};
v.addEventListener('loadedmetadata', onLoaded);
v.addEventListener('timeupdate', onTime);
@@ -56,6 +77,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
v.removeEventListener('pause', onPause);
v.removeEventListener('ended', onEnded);
v.removeEventListener('error', onError);
+ if (blobUrl) URL.revokeObjectURL(blobUrl);
};
}, []);
@@ -125,7 +147,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
if (playing) setShowControls(false);
}}
>
-
+
{loaded && !playing && (
diff --git a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx b/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx
index 4a6eca91..10aaa15f 100644
--- a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx
+++ b/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx
@@ -19,6 +19,7 @@ type PanelSlotProps = {
registry: AppRegistry;
components?: PanelComponents;
interactive: boolean;
+ locked: boolean;
noHeader: boolean;
isLastPanel: boolean;
onSetApp: (panelId: string, appType: string | null) => void;
@@ -164,6 +165,46 @@ const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId
);
};
+const MaximizeButton = ({ panelId }: { panelId: string }) => {
+ const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
+ const isMaximized = maximizedPanelId === panelId;
+
+ return (
+
+
setMaximizedPanelId(isMaximized ? null : panelId)}
+ className="group/btn h-3 w-3 rounded-full bg-[#28c840] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
+ title={isMaximized ? 'Restore' : 'Maximize'}
+ >
+ {isMaximized ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+};
+
+const MaximizeContextMenu = ({ panelId, children }: { panelId: string; children: React.ReactNode }) => {
+ const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
+ const isMaximized = maximizedPanelId === panelId;
+
+ return (
+
+ {children}
+
+ setMaximizedPanelId(isMaximized ? null : panelId)}>
+ {isMaximized ? 'Restore' : 'Maximize'}
+
+
+
+ );
+};
+
// TODO: drag-to-reposition needs work (visual feedback, edge cases)
// const DragHandle = ({ panelId }: { panelId: string }) => {
// const { setDragSourceId, dragSourceId } = useWorkspace();
@@ -182,7 +223,7 @@ const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId
// return
;
// };
-export const PanelSlot = ({ panel, registry, components, interactive, noHeader, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
+export const PanelSlot = ({ panel, registry, components, interactive, locked, noHeader, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
const { maximizedPanelId, transitioningPanelId, isMobile, onMobileBack } = useWorkspace();
const isMaximized = maximizedPanelId === panel.id;
@@ -199,14 +240,18 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
const onClose = panelEntry?.onClose;
const contextMenu = interactive
- ? (content: React.ReactNode) => (
- onSetApp(panel.id, null)}>
- {content}
-
- )
+ ? locked
+ ? (content: React.ReactNode) => (
+ {content}
+ )
+ : (content: React.ReactNode) => (
+ onSetApp(panel.id, null)}>
+ {content}
+
+ )
: (content: React.ReactNode) => <>{content}>;
- const overlays = interactive ? (
+ const overlays = interactive && !locked ? (
<>
@@ -214,7 +259,7 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
) : null;
if (!AppComponent) {
- if (!interactive) {
+ if (!interactive || locked) {
return (
@@ -254,7 +299,11 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
const ResolvedHeader = HeaderComponent ?? DefaultHeader;
const trafficLights = interactive && !isMobile ? (
-
onSetApp(panel.id, null)} />
+ locked ? (
+
+ ) : (
+ onSetApp(panel.id, null)} />
+ )
) : null;
const mobileBackButton = isMobile && onMobileBack ? (
@@ -285,9 +334,13 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
);
const headerBar = interactive ? (
- onSetApp(panel.id, null)}>
- {headerContent}
-
+ locked ? (
+ {headerContent}
+ ) : (
+ onSetApp(panel.id, null)}>
+ {headerContent}
+
+ )
) : headerContent;
const body = (
diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceRenderer.tsx b/src/workspaces/officerdev/src/components/Workspace/WorkspaceRenderer.tsx
index 6b8a88b9..481686aa 100644
--- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceRenderer.tsx
+++ b/src/workspaces/officerdev/src/components/Workspace/WorkspaceRenderer.tsx
@@ -9,6 +9,7 @@ type WorkspaceRendererProps = {
registry: AppRegistry;
components?: PanelComponents;
interactive?: boolean;
+ locked?: boolean;
noHeader?: boolean;
isMobile?: boolean;
mobilePanelId?: string;
@@ -23,6 +24,7 @@ export const WorkspaceRenderer = ({
registry,
components,
interactive = false,
+ locked = false,
noHeader = false,
isMobile = false,
mobilePanelId,
@@ -40,6 +42,7 @@ export const WorkspaceRenderer = ({
registry={registry}
components={components}
interactive={interactive}
+ locked={locked}
noHeader={noHeader}
isMobile={isMobile}
mobilePanelId={mobilePanelId}
@@ -58,6 +61,7 @@ type LayoutNodeRendererProps = {
registry: AppRegistry;
components?: PanelComponents;
interactive: boolean;
+ locked: boolean;
noHeader: boolean;
isMobile: boolean;
mobilePanelId?: string;
@@ -91,6 +95,7 @@ const LayoutNodeRenderer = ({
registry,
components,
interactive,
+ locked,
noHeader,
isMobile,
mobilePanelId,
@@ -125,6 +130,7 @@ const LayoutNodeRenderer = ({
registry={registry}
components={components}
interactive={interactive}
+ locked={locked}
noHeader={noHeader}
isLastPanel={totalPanels <= 1}
onSetApp={onSetApp}
@@ -144,6 +150,7 @@ const LayoutNodeRenderer = ({
registry={registry}
components={components}
interactive={interactive}
+ locked={locked}
noHeader={noHeader}
isMobile={isMobile}
mobilePanelId={mobilePanelId}
@@ -171,6 +178,7 @@ const LayoutNodeRenderer = ({
registry={registry}
components={components}
interactive={interactive}
+ locked={locked}
noHeader={noHeader}
isMobile={isMobile}
mobilePanelId={mobilePanelId}
@@ -198,6 +206,7 @@ const LayoutNodeRenderer = ({
registry={registry}
components={components}
interactive={interactive}
+ locked={locked}
noHeader={noHeader}
isMobile={isMobile}
mobilePanelId={mobilePanelId}
diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx b/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx
index 9df6e169..ebcac4bb 100644
--- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx
+++ b/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx
@@ -12,6 +12,7 @@ import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
type WorkspaceViewProps = {
workspace: WorkspaceState;
+ locked?: boolean;
cwd?: string;
root?: string;
initialFilePath?: string;
@@ -24,7 +25,7 @@ type WorkspaceViewProps = {
const noop = () => {};
-export const WorkspaceView = ({ workspace, cwd = '~', root, initialFilePath, defaultFileSort, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => {
+export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFilePath, defaultFileSort, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => {
const { registry } = useAppRegistry();
const isMobile = useIsMobile();
@@ -178,11 +179,12 @@ export const WorkspaceView = ({ workspace, cwd = '~', root, initialFilePath, def
registry={registry}
components={components}
interactive
+ locked={locked}
isMobile={isMobile}
mobilePanelId={mobilePanelId}
- onSetApp={handleSetApp}
- onSplit={handleSplit}
- onRemove={handleRemove}
+ onSetApp={locked ? noop : handleSetApp}
+ onSplit={locked ? noop : handleSplit}
+ onRemove={locked ? noop : handleRemove}
onResized={handleResized}
/>
diff --git a/src/workspaces/state/src/useSettings.ts b/src/workspaces/state/src/useSettings.ts
index e0a0f0ee..8ead5133 100644
--- a/src/workspaces/state/src/useSettings.ts
+++ b/src/workspaces/state/src/useSettings.ts
@@ -15,6 +15,7 @@ const mergeWithDefaults = (saved: Partial): UserSettings => ({
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages },
+ onboarding: { ...DEFAULT_SETTINGS.onboarding, ...saved.onboarding },
});
export const useSettings = () => {
@@ -72,6 +73,9 @@ export type UserSettings = {
default: string;
translateTo: string;
};
+ onboarding: {
+ complete: boolean;
+ };
};
export type UserState = Record;
@@ -103,4 +107,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
default: 'en',
translateTo: 'en',
},
+ onboarding: {
+ complete: false,
+ },
};