diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx
index 2b2f1757..fcf0a6cf 100644
--- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx
@@ -2,7 +2,33 @@ import { useState } from 'react';
import { Box, Circle, Server, Wrench } from 'lucide-react';
import { useGlobal } from 'hooks/useGlobal';
import { Input } from '@/components/ui/input';
-import { useResources, type Resource } from '@/state/useResources';
+import { useResources, getResourceCategory, type Resource } from '@/state/useResources';
+
+type ResourceItemProps = {
+ resource: Resource;
+ isActive: boolean;
+ onSelect: () => void;
+};
+
+const ResourceItem = ({ resource: r, isActive, onSelect }: ResourceItemProps) => (
+
+);
export const ResourceSidebar = () => {
const { resources, isLoading } = useResources();
@@ -17,6 +43,9 @@ export const ResourceSidebar = () => {
r.description.toLowerCase().includes(query),
);
+ const apiBased = filtered?.filter((r: Resource) => getResourceCategory(r) === 'api-based') ?? [];
+ const localCli = filtered?.filter((r: Resource) => getResourceCategory(r) === 'local-cli') ?? [];
+
return (
@@ -26,35 +55,47 @@ export const ResourceSidebar = () => {
- setSearch(ev.target.value)} className="h-8 text-xs" />
+ setSearch(ev.target.value)}
+ className="h-8 text-xs"
+ />
{isLoading &&
Loading...
}
- {filtered?.map((r: Resource) => {
- const isActive = selectedId === r.id;
- return (
-
- );
- })}
+ ))}
+ >
+ )}
+ {localCli.length > 0 && (
+ <>
+
+
+ Local CLI
+
+ {localCli.map((r: Resource) => (
+
setSelectedId(r.id)}
+ />
+ ))}
+ >
+ )}
);
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx
index 238cf99a..edee15f9 100644
--- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx
@@ -1,7 +1,9 @@
import { useState } from 'react';
-import { Copy, Check, Server, Wrench } from 'lucide-react';
+import { Copy, Check, Server, Wrench, Loader2 } from 'lucide-react';
import { useGlobal } from 'hooks/useGlobal';
-import { useResources, type Resource } from '@/state/useResources';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { useResources, getResourceCategory, type Resource, type PingResult } from '@/state/useResources';
const CopyCommand = ({ command }: { command: string }) => {
const [copied, setCopied] = useState(false);
@@ -33,6 +35,140 @@ const CommandRow = ({ label, command }: { label: string; command: string }) => (
);
+type ConnectionSectionProps = {
+ resource: Resource;
+};
+
+const ConnectionSection = ({ resource }: ConnectionSectionProps) => {
+ const { saveConnectionConfig, pingResource } = useResources();
+ const [url, setUrl] = useState(resource.connectionConfig?.url ?? '');
+ const [apiKey, setApiKey] = useState(resource.connectionConfig?.credentials?.apiKey ?? '');
+ const [username, setUsername] = useState(resource.connectionConfig?.credentials?.username ?? '');
+ const [password, setPassword] = useState(resource.connectionConfig?.credentials?.password ?? '');
+ const [pinging, setPinging] = useState(false);
+ const [pingResult, setPingResult] = useState(null);
+ const [saving, setSaving] = useState(false);
+
+ const handlePing = async () => {
+ setPinging(true);
+ setPingResult(null);
+ try {
+ const result = await pingResource(resource.id, url);
+ setPingResult(result);
+ } catch {
+ setPingResult({ reachable: false, latencyMs: null });
+ } finally {
+ setPinging(false);
+ }
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ const credentials =
+ apiKey || username || password
+ ? { apiKey: apiKey || undefined, username: username || undefined, password: password || undefined }
+ : undefined;
+ await saveConnectionConfig(resource.id, { url, credentials });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const hasCredentials = !!(
+ resource.connectionConfig?.credentials?.apiKey || resource.connectionConfig?.credentials?.username
+ );
+
+ return (
+
+
Connection
+
+
+
+ setUrl(ev.target.value)}
+ placeholder="http://127.0.0.1:64202"
+ className="h-8 text-xs"
+ />
+
+ {(hasCredentials || apiKey) && (
+
+
+ setApiKey(ev.target.value)}
+ placeholder="Optional"
+ type="password"
+ className="h-8 text-xs"
+ />
+
+ )}
+ {(hasCredentials || username || password) && (
+
+ )}
+
+
+
+ {pingResult && (
+
+ {pingResult.reachable ? `Reachable (${pingResult.latencyMs}ms)` : 'Unreachable'}
+
+ )}
+
+
+
+ );
+};
+
+const LocalAvailabilitySection = ({ resource }: { resource: Resource }) => (
+
+
Local Availability
+
+ {resource.installed ? (
+
+ Installed{resource.version ? ` (${resource.version})` : ''}
+
+ ) : (
+ Not installed
+ )}
+
+
+ {resource.installCommand && }
+ {resource.uninstallCommand && }
+ {resource.manageCommand && }
+ {resource.verifyCommand && }
+ {resource.updateCommand && }
+
+
+);
+
export const Resources = () => {
const { resources } = useResources();
const [selectedId] = useGlobal('RESOURCE_SELECTED', null);
@@ -47,6 +183,8 @@ export const Resources = () => {
);
}
+ const category = getResourceCategory(resource);
+
return (
@@ -61,27 +199,20 @@ export const Resources = () => {
{resource.type}
+
+ {category === 'api-based' ? 'API Based' : 'Local CLI'}
+
{resource.port && (
:{resource.port}
)}
- {resource.installed ? (
-
- {resource.version ?? 'Running'}
-
- ) : (
- Not installed
- )}
{resource.description}
-
- {resource.installCommand && }
- {resource.uninstallCommand && }
- {resource.manageCommand && }
- {resource.verifyCommand && }
- {resource.updateCommand && }
-
+ {category === 'api-based' &&
}
+
);
};
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx
index c78d9593..f215bbca 100644
--- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx
@@ -1,10 +1,12 @@
import { useState } from 'react';
-import { Copy, Check } from 'lucide-react';
+import { Copy, Check, Play } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { useClient } from 'hooks/useClient';
+import { usePanelChannel } from 'hooks/usePanelChannel';
import { useServerSettings } from '@/state/useServerSettings';
+import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel';
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string };
@@ -85,15 +87,26 @@ export const AIHarnessesSection = () => {
setTimeout(() => setCopied(null), 1500);
};
- const CopyCommand = ({ command }: { command: string }) => (
+ const [, setRunCommand] = usePanelChannel
(RUN_COMMAND_CHANNEL, null);
+
+ const CopyCommand = ({ command, refetchKeys }: { command: string; refetchKeys: string[] }) => (
Not globally accessible. Run:
{command}
+
)}
{!opencodeVersion.globalPath && opencodeVersion.path && (
-
+
)}
>
) : (
@@ -194,7 +207,7 @@ export const AIHarnessesSection = () => {
)}
{!claudeVersion.globalPath && claudeVersion.path && (
-
+
)}
>
) : (
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx
index f59f348f..4579393d 100644
--- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx
@@ -1,10 +1,14 @@
import { useMemo } from 'react';
-import { Terminal, Server } from 'lucide-react';
+import { Terminal, Server, X } from 'lucide-react';
+import { useQueryClient } from '@tanstack/react-query';
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace';
+import { usePanelChannel } from 'hooks/usePanelChannel';
import { appRegistry } from '../../Workspaces/app-registry';
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
import { AIHarnessesSection } from './AIHarnessesSection';
+import { TerminalView } from 'apps/Terminal';
+import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel';
const GLOBAL_KEY = 'SERVER_SETTINGS_SELECTED';
@@ -19,7 +23,7 @@ const { Sidebar, Content } = createSettingsPanelComponents({
sections,
});
-const layout: LayoutNode = {
+const baseLayout: LayoutNode = {
type: 'group',
id: 'server-root',
direction: 'horizontal',
@@ -29,11 +33,72 @@ const layout: LayoutNode = {
],
};
+const splitLayout: LayoutNode = {
+ type: 'group',
+ id: 'server-root',
+ direction: 'horizontal',
+ children: [
+ { node: { type: 'panel', id: 'server-left', appType: null }, size: 20 },
+ {
+ node: {
+ type: 'group',
+ id: 'server-right-group',
+ direction: 'vertical',
+ children: [
+ { node: { type: 'panel', id: 'server-right', appType: null }, size: 50 },
+ { node: { type: 'panel', id: 'server-terminal', appType: null }, size: 50 },
+ ],
+ },
+ size: 80,
+ },
+ ],
+};
+
+const SystemTerminalPanel = () => {
+ const queryClient = useQueryClient();
+ const [state, setState] = usePanelChannel(RUN_COMMAND_CHANNEL, null);
+
+ const close = () => setState(null);
+
+ const onExit = () => {
+ if (state) {
+ for (const key of state.refetchKeys) {
+ queryClient.invalidateQueries({ queryKey: [key] });
+ }
+ }
+ };
+
+ if (!state) return null;
+
+ return (
+
+
+ Run Command
+
+
+
+
+ );
+};
+
export const ServerSettings = () => {
+ const [runCommand] = usePanelChannel(RUN_COMMAND_CHANNEL, null);
+
+ const layout = useMemo(() => (runCommand ? splitLayout : baseLayout), [runCommand]);
+
const panelComponents: PanelComponents = useMemo(
() => ({
'server-left': Sidebar,
'server-right': Content,
+ 'server-terminal': SystemTerminalPanel,
}),
[],
);
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts
new file mode 100644
index 00000000..60db6033
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts
@@ -0,0 +1,6 @@
+export type RunCommandState = {
+ command: string;
+ refetchKeys: string[];
+} | null;
+
+export const RUN_COMMAND_CHANNEL = 'server-settings:run-command';
diff --git a/src/apps/officer-web/state/useResources.ts b/src/apps/officer-web/state/useResources.ts
index f4674dfc..2fabde42 100644
--- a/src/apps/officer-web/state/useResources.ts
+++ b/src/apps/officer-web/state/useResources.ts
@@ -1,6 +1,17 @@
-import { useQuery } from '@tanstack/react-query';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
+export type ResourceCredentials = {
+ apiKey?: string;
+ username?: string;
+ password?: string;
+};
+
+export type ResourceConnectionConfig = {
+ url: string;
+ credentials?: ResourceCredentials;
+};
+
export type Resource = {
id: string;
name: string;
@@ -15,17 +26,37 @@ export type Resource = {
updateCommand: string | null;
installed: boolean;
version: string | null;
+ connectionConfig: ResourceConnectionConfig | null;
};
+export type PingResult = {
+ reachable: boolean;
+ latencyMs: number | null;
+};
+
+export type ResourceCategory = 'api-based' | 'local-cli';
+
+export const getResourceCategory = (r: Resource): ResourceCategory => (r.port ? 'api-based' : 'local-cli');
+
const RESOURCES_KEY = ['RESOURCES'];
export const useResources = () => {
const client = useClient();
+ const queryClient = useQueryClient();
const { data: resources, isLoading } = useQuery({
queryKey: RESOURCES_KEY,
queryFn: () => client.get('/server-settings/resources'),
});
- return { resources, isLoading };
+ const saveConnectionConfig = async (id: string, config: Partial) => {
+ await client.patch(`/server-settings/resources/config/${id}`, config);
+ queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
+ };
+
+ const pingResource = async (id: string, url?: string) => {
+ return client.post(`/server-settings/resources/${id}/ping`, { url });
+ };
+
+ return { resources, isLoading, saveConnectionConfig, pingResource };
};
diff --git a/src/server.tsx b/src/server.tsx
index 72743125..04d60ef7 100644
--- a/src/server.tsx
+++ b/src/server.tsx
@@ -20,6 +20,7 @@ type WSData = {
sandboxed: boolean;
sessionId?: string;
cwd?: string;
+ command?: string;
};
const handlers: Record = {
@@ -47,8 +48,9 @@ async function upgradeWs(req: Request, server: any, provider: 'claude' | 'openco
const sessionId = url.searchParams.get('sessionId') ?? undefined;
const sandboxed = url.searchParams.get('sandboxed') !== 'false';
const cwd = url.searchParams.get('cwd') ?? undefined;
+ const command = url.searchParams.get('command') ?? undefined;
const ok = server.upgrade(req, {
- data: { userId: user.id, email: user.email, role: user.role, provider, sandboxed, sessionId, cwd },
+ data: { userId: user.id, email: user.email, role: user.role, provider, sandboxed, sessionId, cwd, command },
});
if (!ok) return new Response('Upgrade failed', { status: 500 });
} catch {
diff --git a/src/servers/api/server-settings/resources.ts b/src/servers/api/server-settings/resources.ts
index 441c7675..54781e17 100644
--- a/src/servers/api/server-settings/resources.ts
+++ b/src/servers/api/server-settings/resources.ts
@@ -1,8 +1,22 @@
-import { readdir } from 'node:fs/promises';
+import { readdir, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
+import { join } from 'node:path';
import { createRouter } from '../../create-router';
import { getResourcesDir } from '../../data-path';
+type ResourceCredentials = {
+ apiKey?: string;
+ username?: string;
+ password?: string;
+};
+
+type ResourceConnectionConfig = {
+ url: string;
+ credentials?: ResourceCredentials;
+};
+
+type ResourcesConfig = Record;
+
type Resource = {
id: string;
name: string;
@@ -18,11 +32,15 @@ type Resource = {
updateCommand: string | null;
installed: boolean;
version: string | null;
+ connectionConfig: ResourceConnectionConfig | null;
};
const stripBackticks = (value: string) => value.replace(/^`(.+)`$/, '$1');
-function parseResourceFile(filename: string, content: string): Omit {
+function parseResourceFile(
+ filename: string,
+ content: string,
+): Omit {
const id = filename
.replace(/^SERVICE_/, '')
.replace(/\.md$/, '')
@@ -60,6 +78,23 @@ function parseResourceFile(filename: string, content: string): Omit join(getResourcesDir(), CONFIG_FILENAME);
+
+async function readConfig(): Promise {
+ const path = getConfigPath();
+ if (!existsSync(path)) return {};
+ const text = await Bun.file(path).text();
+ return JSON.parse(text) as ResourcesConfig;
+}
+
+async function writeConfig(config: ResourcesConfig): Promise {
+ const dir = getResourcesDir();
+ if (!existsSync(dir)) await mkdir(dir, { recursive: true });
+ await Bun.write(getConfigPath(), JSON.stringify(config, null, 2));
+}
+
const CHECK_TIMEOUT_MS = 3_000;
async function checkPort(port: number): Promise {
@@ -107,14 +142,36 @@ function extractFirstPort(portStr: string): number | null {
return match ? parseInt(match[1]!, 10) : null;
}
-async function checkResourceStatus(
- resource: Omit,
-): Promise<{ installed: boolean; version: string | null }> {
+async function checkUrl(url: string): Promise {
+ try {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
+ await fetch(url, { signal: controller.signal });
+ clearTimeout(timeout);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+type CheckResourceStatusParams = {
+ resource: Omit;
+ configUrl?: string;
+};
+
+async function checkResourceStatus({
+ resource,
+ configUrl,
+}: CheckResourceStatusParams): Promise<{ installed: boolean; version: string | null }> {
if (resource.path) {
const fullPath = `${getResourcesDir()}/${resource.path}`;
return { installed: existsSync(fullPath), version: null };
}
if (resource.port) {
+ if (configUrl) {
+ const reachable = await checkUrl(configUrl);
+ if (reachable) return { installed: true, version: null };
+ }
const port = extractFirstPort(resource.port);
if (port) {
const reachable = await checkPort(port);
@@ -127,10 +184,20 @@ async function checkResourceStatus(
return { installed: false, version: null };
}
+function buildConnectionConfig(
+ resource: Omit,
+ config: ResourcesConfig,
+): ResourceConnectionConfig | null {
+ if (!resource.port) return null;
+ if (config[resource.id]) return config[resource.id]!;
+ const port = extractFirstPort(resource.port);
+ return { url: `http://127.0.0.1:${port ?? resource.port}` };
+}
+
async function loadResources(): Promise {
const dir = getResourcesDir();
if (!existsSync(dir)) return [];
- const files = await readdir(dir);
+ const [files, config] = await Promise.all([readdir(dir), readConfig()]);
const serviceFiles = files.filter((f) => f.startsWith('SERVICE_') && f.endsWith('.md'));
const parsed = await Promise.all(
@@ -142,8 +209,10 @@ async function loadResources(): Promise {
const resources = await Promise.all(
parsed.map(async (r) => {
- const status = await checkResourceStatus(r);
- return { ...r, ...status };
+ const configUrl = config[r.id]?.url;
+ const status = await checkResourceStatus({ resource: r, configUrl });
+ const connectionConfig = buildConnectionConfig(r, config);
+ return { ...r, ...status, connectionConfig };
}),
);
@@ -157,6 +226,45 @@ resourcesRouter.get('/', async (ctx) => {
return ctx.json(resources);
});
+resourcesRouter.get('/config', async (ctx) => {
+ const config = await readConfig();
+ return ctx.json(config);
+});
+
+resourcesRouter.patch('/config/:id', async (ctx) => {
+ const id = ctx.req.param('id');
+ const body = await ctx.req.json>();
+ const config = await readConfig();
+ const existing = config[id] ?? { url: '' };
+ config[id] = { ...existing, ...body };
+ await writeConfig(config);
+ return ctx.json(config[id]);
+});
+
+resourcesRouter.post('/:id/ping', async (ctx) => {
+ const id = ctx.req.param('id');
+ const body = await ctx.req.json<{ url?: string }>().catch((): { url?: string } => ({}));
+ const config = await readConfig();
+ const resources = await loadResources();
+ const resource = resources.find((r) => r.id === id);
+ if (!resource) return ctx.json({ error: 'Resource not found' }, 404);
+
+ const url = body.url ?? config[id]?.url ?? resource.connectionConfig?.url;
+ if (!url) return ctx.json({ error: 'No URL configured' }, 400);
+
+ try {
+ const start = performance.now();
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
+ await fetch(url, { signal: controller.signal });
+ clearTimeout(timeout);
+ const latencyMs = Math.round(performance.now() - start);
+ return ctx.json({ reachable: true, latencyMs });
+ } catch {
+ return ctx.json({ reachable: false, latencyMs: null });
+ }
+});
+
resourcesRouter.get('/:id', async (ctx) => {
const resources = await loadResources();
const resource = resources.find((r) => r.id === ctx.req.param('id'));
diff --git a/src/workspaces/apps/Terminal/Terminal.tsx b/src/workspaces/apps/Terminal/Terminal.tsx
index a04ae801..8de36422 100644
--- a/src/workspaces/apps/Terminal/Terminal.tsx
+++ b/src/workspaces/apps/Terminal/Terminal.tsx
@@ -19,6 +19,7 @@ export type TerminalViewProps = {
sessionId?: string;
sandboxed?: boolean;
cwd?: string;
+ command?: string;
fontSize?: number;
fontFamily?: string;
theme?: TerminalTheme;
@@ -35,7 +36,7 @@ const DEFAULT_THEME: Required = {
selectionBackground: '#3a3a5e',
};
-const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string) => {
+const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string, command?: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
const separator = wsPath.includes('?') ? '&' : '?';
@@ -43,6 +44,7 @@ const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd
if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`;
if (sandboxed === false) url += '&sandboxed=false';
if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
+ if (command) url += `&command=${encodeURIComponent(command)}`;
return url;
};
@@ -53,6 +55,7 @@ export const TerminalView = ({
sessionId,
sandboxed = true,
cwd,
+ command,
fontSize = 14,
fontFamily = 'Menlo, Monaco, "Courier New", monospace',
theme,
@@ -116,7 +119,7 @@ export const TerminalView = ({
fitAddonRef.current = fitAddon;
onReadyRef.current?.(term);
- const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd));
+ const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd, command));
wsRef.current = ws;
const handleOpen = () => {
@@ -191,6 +194,7 @@ export const TerminalView = ({
sessionId,
sandboxed,
cwd,
+ command,
fontSize,
fontFamily,
background,